fedbox 0.0.5 → 0.0.7

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,427 @@
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
+ return actor
76
+ }
77
+
78
+ /**
79
+ * Render profile HTML
80
+ */
81
+ function renderProfile(actor, baseUrl) {
82
+ const avatarUrl = config.avatar ? `/public/${config.avatar}` : ''
83
+
84
+ return `<!DOCTYPE html>
85
+ <html>
86
+ <head>
87
+ <meta charset="utf-8">
88
+ <title>${config.displayName} (@${config.username})</title>
89
+ <meta name="viewport" content="width=device-width, initial-scale=1">
90
+ <link rel="alternate" type="application/activity+json" href="${actor.url}">
91
+ <script type="application/ld+json" id="profile">
92
+ ${JSON.stringify(actor, null, 2)}
93
+ </script>
94
+ <style>
95
+ * { box-sizing: border-box; }
96
+ body {
97
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
98
+ max-width: 600px;
99
+ margin: 0 auto;
100
+ padding: 2rem;
101
+ background: #1a1a2e;
102
+ color: #eee;
103
+ min-height: 100vh;
104
+ }
105
+ .card {
106
+ background: #16213e;
107
+ border-radius: 16px;
108
+ padding: 2rem;
109
+ text-align: center;
110
+ position: relative;
111
+ }
112
+ .edit-btn {
113
+ position: absolute;
114
+ top: 1rem;
115
+ right: 1rem;
116
+ background: #667eea;
117
+ color: white;
118
+ border: none;
119
+ padding: 0.5rem 1rem;
120
+ border-radius: 8px;
121
+ cursor: pointer;
122
+ font-size: 0.9rem;
123
+ }
124
+ .edit-btn:hover { background: #5a6fd6; }
125
+ .avatar {
126
+ width: 120px;
127
+ height: 120px;
128
+ border-radius: 50%;
129
+ background: linear-gradient(135deg, #667eea, #764ba2);
130
+ margin: 0 auto 1rem;
131
+ display: flex;
132
+ align-items: center;
133
+ justify-content: center;
134
+ font-size: 3rem;
135
+ overflow: hidden;
136
+ cursor: pointer;
137
+ position: relative;
138
+ }
139
+ .avatar img { width: 100%; height: 100%; object-fit: cover; }
140
+ .avatar-overlay {
141
+ display: none;
142
+ position: absolute;
143
+ inset: 0;
144
+ background: rgba(0,0,0,0.5);
145
+ align-items: center;
146
+ justify-content: center;
147
+ font-size: 1rem;
148
+ }
149
+ .editing .avatar-overlay { display: flex; }
150
+ .avatar-input { display: none; }
151
+ h1 { margin: 0 0 0.5rem; }
152
+ .handle { color: #888; margin-bottom: 1rem; }
153
+ .bio { color: #aaa; margin-bottom: 1.5rem; }
154
+ .badge {
155
+ display: inline-block;
156
+ background: #667eea;
157
+ color: white;
158
+ padding: 0.5rem 1rem;
159
+ border-radius: 20px;
160
+ margin-top: 1.5rem;
161
+ font-size: 0.9rem;
162
+ }
163
+ .edit-input {
164
+ background: #0f172a;
165
+ border: 1px solid #667eea;
166
+ color: #eee;
167
+ padding: 0.5rem;
168
+ border-radius: 8px;
169
+ font-size: inherit;
170
+ text-align: center;
171
+ width: 100%;
172
+ max-width: 300px;
173
+ }
174
+ .edit-textarea {
175
+ background: #0f172a;
176
+ border: 1px solid #667eea;
177
+ color: #aaa;
178
+ padding: 0.5rem;
179
+ border-radius: 8px;
180
+ font-size: 1rem;
181
+ text-align: center;
182
+ width: 100%;
183
+ max-width: 400px;
184
+ resize: vertical;
185
+ min-height: 60px;
186
+ }
187
+ .edit-actions {
188
+ display: none;
189
+ gap: 0.5rem;
190
+ justify-content: center;
191
+ margin-top: 1rem;
192
+ }
193
+ .editing .edit-actions { display: flex; }
194
+ .save-btn { background: #22c55e; color: white; border: none; padding: 0.5rem 1.5rem; border-radius: 8px; cursor: pointer; }
195
+ .cancel-btn { background: #64748b; color: white; border: none; padding: 0.5rem 1.5rem; border-radius: 8px; cursor: pointer; }
196
+ .view-mode { display: block; }
197
+ .edit-mode { display: none; }
198
+ .editing .view-mode { display: none; }
199
+ .editing .edit-mode { display: block; }
200
+ .editing .edit-btn { display: none; }
201
+ </style>
202
+ </head>
203
+ <body>
204
+ <div class="card" id="profile-card">
205
+ <button class="edit-btn" onclick="toggleEdit()">Edit</button>
206
+
207
+ <div class="avatar" onclick="document.getElementById('avatar-input').click()">
208
+ ${avatarUrl ? `<img src="${avatarUrl}" alt="avatar">` : '📦'}
209
+ <div class="avatar-overlay">Change</div>
210
+ </div>
211
+ <input type="file" id="avatar-input" class="avatar-input" accept="image/*" onchange="previewAvatar(this)">
212
+
213
+ <div class="view-mode"><h1>${config.displayName}</h1></div>
214
+ <div class="edit-mode">
215
+ <input type="text" class="edit-input" id="edit-name" value="${config.displayName}" placeholder="Display Name">
216
+ </div>
217
+
218
+ <p class="handle">@${config.username}</p>
219
+
220
+ <div class="view-mode">
221
+ ${config.summary ? `<p class="bio">${config.summary}</p>` : '<p class="bio" style="opacity:0.5">No bio yet</p>'}
222
+ </div>
223
+ <div class="edit-mode">
224
+ <textarea class="edit-textarea" id="edit-summary" placeholder="Write a short bio...">${config.summary || ''}</textarea>
225
+ </div>
226
+
227
+ <div class="edit-actions">
228
+ <button class="save-btn" onclick="saveProfile()">Save</button>
229
+ <button class="cancel-btn" onclick="toggleEdit()">Cancel</button>
230
+ </div>
231
+
232
+ <div class="badge">🪪 Profile</div>
233
+ </div>
234
+
235
+ <script>
236
+ let avatarFile = null;
237
+ function toggleEdit() {
238
+ document.getElementById('profile-card').classList.toggle('editing');
239
+ avatarFile = null;
240
+ }
241
+ function previewAvatar(input) {
242
+ if (input.files && input.files[0]) {
243
+ avatarFile = input.files[0];
244
+ const reader = new FileReader();
245
+ reader.onload = (e) => {
246
+ const avatar = document.querySelector('.avatar');
247
+ avatar.innerHTML = '<img src="' + e.target.result + '" alt="avatar"><div class="avatar-overlay">Change</div>';
248
+ };
249
+ reader.readAsDataURL(input.files[0]);
250
+ }
251
+ }
252
+ async function saveProfile() {
253
+ const formData = new FormData();
254
+ formData.append('displayName', document.getElementById('edit-name').value);
255
+ formData.append('summary', document.getElementById('edit-summary').value);
256
+ if (avatarFile) formData.append('avatar', avatarFile);
257
+ try {
258
+ const res = await fetch('/edit', { method: 'POST', body: formData });
259
+ if (res.ok) location.reload();
260
+ else alert('Failed to save');
261
+ } catch (err) { alert('Error: ' + err.message); }
262
+ }
263
+ </script>
264
+ </body>
265
+ </html>`
266
+ }
267
+
268
+ /**
269
+ * Parse multipart form data
270
+ */
271
+ function parseMultipart(body, boundary) {
272
+ const parts = {}
273
+ const boundaryBuffer = Buffer.from('--' + boundary)
274
+ const doubleCrlf = Buffer.from('\r\n\r\n')
275
+
276
+ let start = 0
277
+ while (true) {
278
+ const boundaryPos = body.indexOf(boundaryBuffer, start)
279
+ if (boundaryPos === -1) break
280
+ const afterBoundary = boundaryPos + boundaryBuffer.length
281
+ if (body.slice(afterBoundary, afterBoundary + 2).toString() === '--') break
282
+ const headersEnd = body.indexOf(doubleCrlf, afterBoundary)
283
+ if (headersEnd === -1) break
284
+ const headers = body.slice(afterBoundary + 2, headersEnd).toString()
285
+ const nextBoundary = body.indexOf(boundaryBuffer, headersEnd)
286
+ const content = body.slice(headersEnd + 4, nextBoundary - 2)
287
+ const nameMatch = headers.match(/name="([^"]+)"/)
288
+ const filenameMatch = headers.match(/filename="([^"]+)"/)
289
+ const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/)
290
+ if (nameMatch) {
291
+ const name = nameMatch[1]
292
+ if (filenameMatch) {
293
+ parts[name] = {
294
+ filename: filenameMatch[1],
295
+ contentType: contentTypeMatch ? contentTypeMatch[1] : 'application/octet-stream',
296
+ data: content
297
+ }
298
+ } else {
299
+ parts[name] = content.toString()
300
+ }
301
+ }
302
+ start = nextBoundary
303
+ }
304
+ return parts
305
+ }
306
+
307
+ /**
308
+ * Handle profile edit
309
+ */
310
+ async function handleEdit(req, res) {
311
+ const chunks = []
312
+ for await (const chunk of req) chunks.push(chunk)
313
+ const body = Buffer.concat(chunks)
314
+
315
+ const contentType = req.headers['content-type'] || ''
316
+ const boundary = contentType.split('boundary=')[1]
317
+ if (!boundary) {
318
+ res.writeHead(400)
319
+ return res.end('Bad request')
320
+ }
321
+
322
+ const parts = parseMultipart(body, boundary)
323
+ let updated = false
324
+
325
+ if (parts.displayName !== undefined) {
326
+ config.displayName = parts.displayName
327
+ updated = true
328
+ }
329
+ if (parts.summary !== undefined) {
330
+ config.summary = parts.summary
331
+ updated = true
332
+ }
333
+ if (parts.avatar && parts.avatar.data && parts.avatar.data.length > 0) {
334
+ if (!existsSync('public')) mkdirSync('public', { recursive: true })
335
+ const ext = parts.avatar.contentType?.includes('png') ? 'png' :
336
+ parts.avatar.contentType?.includes('gif') ? 'gif' : 'jpg'
337
+ const filename = `avatar.${ext}`
338
+ writeFileSync(join('public', filename), parts.avatar.data)
339
+ config.avatar = filename
340
+ updated = true
341
+ console.log(`📷 Avatar saved: public/${filename}`)
342
+ }
343
+
344
+ if (updated) {
345
+ writeFileSync('fedbox.json', JSON.stringify(config, null, 2))
346
+ console.log('✅ Profile updated')
347
+ }
348
+
349
+ res.writeHead(200)
350
+ res.end('OK')
351
+ }
352
+
353
+ /**
354
+ * Start the profile server
355
+ */
356
+ export async function startProfileServer(port = 3000) {
357
+ loadConfig()
358
+ const baseUrl = getBaseUrl(port)
359
+ const actor = buildActor(baseUrl)
360
+
361
+ const server = createServer(async (req, res) => {
362
+ const url = new URL(req.url, baseUrl)
363
+ const path = url.pathname
364
+ const accept = req.headers.accept || ''
365
+ const isAP = accept.includes('activity+json') || accept.includes('ld+json')
366
+
367
+ // CORS
368
+ res.setHeader('Access-Control-Allow-Origin', '*')
369
+ res.setHeader('Access-Control-Allow-Headers', '*')
370
+ if (req.method === 'OPTIONS') {
371
+ res.writeHead(204)
372
+ return res.end()
373
+ }
374
+
375
+ console.log(`${req.method} ${path}`)
376
+
377
+ // Profile edit
378
+ if (path === '/edit' && req.method === 'POST') {
379
+ return handleEdit(req, res)
380
+ }
381
+
382
+ // Static files
383
+ if (path.startsWith('/public/')) {
384
+ const filePath = join(process.cwd(), path)
385
+ if (existsSync(filePath)) {
386
+ const ext = extname(filePath)
387
+ const mimeTypes = {
388
+ '.js': 'application/javascript',
389
+ '.css': 'text/css',
390
+ '.png': 'image/png',
391
+ '.jpg': 'image/jpeg',
392
+ '.gif': 'image/gif',
393
+ '.svg': 'image/svg+xml'
394
+ }
395
+ res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream')
396
+ return res.end(readFileSync(filePath))
397
+ }
398
+ }
399
+
400
+ // Profile (root or /username)
401
+ if (path === '/' || path === `/${config.username}`) {
402
+ if (isAP) {
403
+ res.setHeader('Content-Type', 'application/activity+json')
404
+ return res.end(JSON.stringify(actor, null, 2))
405
+ }
406
+ res.setHeader('Content-Type', 'text/html')
407
+ return res.end(renderProfile(actor, baseUrl))
408
+ }
409
+
410
+ res.writeHead(404)
411
+ res.end('Not found')
412
+ })
413
+
414
+ server.listen(port, () => {
415
+ console.log(`
416
+ 🪪 Profile server running!
417
+
418
+ Profile: http://localhost:${port}/
419
+ WebID: http://localhost:${port}/${config.username}#me
420
+ JSON-LD: curl -H "Accept: application/activity+json" http://localhost:${port}/
421
+
422
+ Press Ctrl+C to stop
423
+ `)
424
+ })
425
+ }
426
+
427
+ export default { startProfileServer }
package/lib/server.js CHANGED
@@ -5,7 +5,8 @@
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
+ import { join, extname } from 'path'
9
10
  import { profile, auth, webfinger, outbox } from 'microfed'
10
11
  import {
11
12
  initStore,
@@ -76,7 +77,7 @@ function buildActor() {
76
77
  const actorId = `${profileUrl}#me`
77
78
 
78
79
  // Build actor manually for more control over structure
79
- return {
80
+ const actor = {
80
81
  '@context': [
81
82
  'https://www.w3.org/ns/activitystreams',
82
83
  'https://w3id.org/security/v1'
@@ -100,6 +101,18 @@ function buildActor() {
100
101
  publicKeyPem: config.publicKey
101
102
  }
102
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
+ return actor
103
116
  }
104
117
 
105
118
  /**
@@ -383,7 +396,7 @@ async function handleRequest(req, res) {
383
396
  version: '2.1',
384
397
  software: {
385
398
  name: 'fedbox',
386
- version: '0.0.5',
399
+ version: '0.0.7',
387
400
  repository: 'https://github.com/micro-fed/fedbox'
388
401
  },
389
402
  protocols: ['activitypub'],
@@ -420,6 +433,11 @@ async function handleRequest(req, res) {
420
433
  }
421
434
  }
422
435
 
436
+ // Profile edit
437
+ if (path === `/${config.username}/edit` && req.method === 'POST') {
438
+ return handleProfileEdit(req, res)
439
+ }
440
+
423
441
  // Inbox
424
442
  if (path === `/${config.username}/inbox`) {
425
443
  return handleInbox(req, res)
@@ -523,6 +541,25 @@ async function handleRequest(req, res) {
523
541
  return res.end(renderHome())
524
542
  }
525
543
 
544
+ // Static files from /public/
545
+ if (path.startsWith('/public/')) {
546
+ const filePath = join(process.cwd(), path)
547
+ if (existsSync(filePath)) {
548
+ const ext = extname(filePath)
549
+ const mimeTypes = {
550
+ '.js': 'application/javascript',
551
+ '.css': 'text/css',
552
+ '.html': 'text/html',
553
+ '.json': 'application/json',
554
+ '.png': 'image/png',
555
+ '.jpg': 'image/jpeg',
556
+ '.svg': 'image/svg+xml'
557
+ }
558
+ res.setHeader('Content-Type', mimeTypes[ext] || 'text/plain')
559
+ return res.end(readFileSync(filePath))
560
+ }
561
+ }
562
+
526
563
  res.writeHead(404)
527
564
  res.end('Not found')
528
565
  }
@@ -561,13 +598,131 @@ async function handleInbox(req, res) {
561
598
  }
562
599
 
563
600
  /**
564
- * Render HTML profile with embedded JSON-LD
601
+ * Handle profile edit POST (multipart form data)
602
+ */
603
+ async function handleProfileEdit(req, res) {
604
+ const chunks = []
605
+ for await (const chunk of req) chunks.push(chunk)
606
+ const body = Buffer.concat(chunks)
607
+
608
+ // Parse multipart form data
609
+ const contentType = req.headers['content-type'] || ''
610
+ const boundary = contentType.split('boundary=')[1]
611
+
612
+ if (!boundary) {
613
+ res.writeHead(400)
614
+ return res.end('Bad request')
615
+ }
616
+
617
+ const parts = parseMultipart(body, boundary)
618
+ let updated = false
619
+
620
+ // Update display name
621
+ if (parts.displayName !== undefined) {
622
+ config.displayName = parts.displayName
623
+ updated = true
624
+ }
625
+
626
+ // Update summary
627
+ if (parts.summary !== undefined) {
628
+ config.summary = parts.summary
629
+ updated = true
630
+ }
631
+
632
+ // Handle avatar upload
633
+ if (parts.avatar && parts.avatar.data && parts.avatar.data.length > 0) {
634
+ // Ensure public directory exists
635
+ if (!existsSync('public')) {
636
+ mkdirSync('public', { recursive: true })
637
+ }
638
+
639
+ // Determine file extension from content type
640
+ const ext = parts.avatar.contentType?.includes('png') ? 'png' :
641
+ parts.avatar.contentType?.includes('gif') ? 'gif' : 'jpg'
642
+ const filename = `avatar.${ext}`
643
+
644
+ writeFileSync(join('public', filename), parts.avatar.data)
645
+ config.avatar = filename
646
+ updated = true
647
+ console.log(`📷 Avatar saved: public/${filename}`)
648
+ }
649
+
650
+ if (updated) {
651
+ // Save config
652
+ writeFileSync('fedbox.json', JSON.stringify(config, null, 2))
653
+ // Rebuild actor with new config
654
+ actor = buildActor()
655
+ console.log('✅ Profile updated')
656
+ }
657
+
658
+ res.writeHead(200)
659
+ res.end('OK')
660
+ }
661
+
662
+ /**
663
+ * Parse multipart form data
664
+ */
665
+ function parseMultipart(body, boundary) {
666
+ const parts = {}
667
+ const boundaryBuffer = Buffer.from('--' + boundary)
668
+ const crlf = Buffer.from('\r\n')
669
+ const doubleCrlf = Buffer.from('\r\n\r\n')
670
+
671
+ let start = 0
672
+ while (true) {
673
+ // Find next boundary
674
+ const boundaryPos = body.indexOf(boundaryBuffer, start)
675
+ if (boundaryPos === -1) break
676
+
677
+ // Check for end boundary
678
+ const afterBoundary = boundaryPos + boundaryBuffer.length
679
+ if (body.slice(afterBoundary, afterBoundary + 2).toString() === '--') break
680
+
681
+ // Find headers end
682
+ const headersEnd = body.indexOf(doubleCrlf, afterBoundary)
683
+ if (headersEnd === -1) break
684
+
685
+ const headers = body.slice(afterBoundary + 2, headersEnd).toString()
686
+
687
+ // Find next boundary for content end
688
+ const nextBoundary = body.indexOf(boundaryBuffer, headersEnd)
689
+ const content = body.slice(headersEnd + 4, nextBoundary - 2) // -2 for CRLF before boundary
690
+
691
+ // Parse headers
692
+ const nameMatch = headers.match(/name="([^"]+)"/)
693
+ const filenameMatch = headers.match(/filename="([^"]+)"/)
694
+ const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/)
695
+
696
+ if (nameMatch) {
697
+ const name = nameMatch[1]
698
+ if (filenameMatch) {
699
+ // File upload
700
+ parts[name] = {
701
+ filename: filenameMatch[1],
702
+ contentType: contentTypeMatch ? contentTypeMatch[1] : 'application/octet-stream',
703
+ data: content
704
+ }
705
+ } else {
706
+ // Regular field
707
+ parts[name] = content.toString()
708
+ }
709
+ }
710
+
711
+ start = nextBoundary
712
+ }
713
+
714
+ return parts
715
+ }
716
+
717
+ /**
718
+ * Render HTML profile with embedded JSON-LD and inline editing
565
719
  */
566
720
  function renderProfile() {
567
721
  const followers = getFollowerCount()
568
722
  const following = getFollowingCount()
569
723
  const posts = getPosts(10)
570
724
  const profileUrl = `${getBaseUrl()}/${config.username}`
725
+ const avatarUrl = config.avatar ? `/public/${config.avatar}` : ''
571
726
 
572
727
  return `<!DOCTYPE html>
573
728
  <html>
@@ -576,7 +731,7 @@ function renderProfile() {
576
731
  <title>${config.displayName} (@${config.username}@${getDomain()})</title>
577
732
  <meta name="viewport" content="width=device-width, initial-scale=1">
578
733
  <link rel="alternate" type="application/activity+json" href="${profileUrl}">
579
- <script type="application/ld+json">
734
+ <script type="application/ld+json" id="profile">
580
735
  ${JSON.stringify(actor, null, 2)}
581
736
  </script>
582
737
  <style>
@@ -596,7 +751,21 @@ ${JSON.stringify(actor, null, 2)}
596
751
  padding: 2rem;
597
752
  text-align: center;
598
753
  margin-bottom: 1.5rem;
754
+ position: relative;
599
755
  }
756
+ .edit-btn {
757
+ position: absolute;
758
+ top: 1rem;
759
+ right: 1rem;
760
+ background: #667eea;
761
+ color: white;
762
+ border: none;
763
+ padding: 0.5rem 1rem;
764
+ border-radius: 8px;
765
+ cursor: pointer;
766
+ font-size: 0.9rem;
767
+ }
768
+ .edit-btn:hover { background: #5a6fd6; }
600
769
  .avatar {
601
770
  width: 120px;
602
771
  height: 120px;
@@ -607,7 +776,26 @@ ${JSON.stringify(actor, null, 2)}
607
776
  align-items: center;
608
777
  justify-content: center;
609
778
  font-size: 3rem;
779
+ overflow: hidden;
780
+ cursor: pointer;
781
+ position: relative;
782
+ }
783
+ .avatar img {
784
+ width: 100%;
785
+ height: 100%;
786
+ object-fit: cover;
787
+ }
788
+ .avatar-overlay {
789
+ display: none;
790
+ position: absolute;
791
+ inset: 0;
792
+ background: rgba(0,0,0,0.5);
793
+ align-items: center;
794
+ justify-content: center;
795
+ font-size: 1rem;
610
796
  }
797
+ .editing .avatar-overlay { display: flex; }
798
+ .avatar-input { display: none; }
611
799
  h1 { margin: 0 0 0.5rem; }
612
800
  .handle { color: #888; margin-bottom: 1rem; }
613
801
  .bio { color: #aaa; margin-bottom: 1.5rem; }
@@ -634,14 +822,88 @@ ${JSON.stringify(actor, null, 2)}
634
822
  .post-content { margin-bottom: 0.5rem; }
635
823
  .post-meta { color: #666; font-size: 0.8rem; }
636
824
  .post a { color: #667eea; text-decoration: none; }
825
+ /* Edit mode */
826
+ .edit-input {
827
+ background: #0f172a;
828
+ border: 1px solid #667eea;
829
+ color: #eee;
830
+ padding: 0.5rem;
831
+ border-radius: 8px;
832
+ font-size: inherit;
833
+ text-align: center;
834
+ width: 100%;
835
+ max-width: 300px;
836
+ }
837
+ .edit-input:focus { outline: none; border-color: #818cf8; }
838
+ .edit-textarea {
839
+ background: #0f172a;
840
+ border: 1px solid #667eea;
841
+ color: #aaa;
842
+ padding: 0.5rem;
843
+ border-radius: 8px;
844
+ font-size: 1rem;
845
+ text-align: center;
846
+ width: 100%;
847
+ max-width: 400px;
848
+ resize: vertical;
849
+ min-height: 60px;
850
+ }
851
+ .edit-actions {
852
+ display: none;
853
+ gap: 0.5rem;
854
+ justify-content: center;
855
+ margin-top: 1rem;
856
+ }
857
+ .editing .edit-actions { display: flex; }
858
+ .save-btn {
859
+ background: #22c55e;
860
+ color: white;
861
+ border: none;
862
+ padding: 0.5rem 1.5rem;
863
+ border-radius: 8px;
864
+ cursor: pointer;
865
+ }
866
+ .cancel-btn {
867
+ background: #64748b;
868
+ color: white;
869
+ border: none;
870
+ padding: 0.5rem 1.5rem;
871
+ border-radius: 8px;
872
+ cursor: pointer;
873
+ }
874
+ .view-mode { display: block; }
875
+ .edit-mode { display: none; }
876
+ .editing .view-mode { display: none; }
877
+ .editing .edit-mode { display: block; }
878
+ .editing .edit-btn { display: none; }
637
879
  </style>
638
880
  </head>
639
881
  <body>
640
- <div class="card">
641
- <div class="avatar">📦</div>
642
- <h1>${config.displayName}</h1>
882
+ <div class="card" id="profile-card">
883
+ <button class="edit-btn" onclick="toggleEdit()">Edit</button>
884
+
885
+ <div class="avatar" onclick="document.getElementById('avatar-input').click()">
886
+ ${avatarUrl ? `<img src="${avatarUrl}" alt="avatar">` : '📦'}
887
+ <div class="avatar-overlay">Change</div>
888
+ </div>
889
+ <input type="file" id="avatar-input" class="avatar-input" accept="image/*" onchange="previewAvatar(this)">
890
+
891
+ <div class="view-mode">
892
+ <h1>${config.displayName}</h1>
893
+ </div>
894
+ <div class="edit-mode">
895
+ <input type="text" class="edit-input" id="edit-name" value="${config.displayName}" placeholder="Display Name">
896
+ </div>
897
+
643
898
  <p class="handle">@${config.username}@${getDomain()}</p>
644
- ${config.summary ? `<p class="bio">${config.summary}</p>` : ''}
899
+
900
+ <div class="view-mode">
901
+ ${config.summary ? `<p class="bio">${config.summary}</p>` : '<p class="bio" style="opacity:0.5">No bio yet</p>'}
902
+ </div>
903
+ <div class="edit-mode">
904
+ <textarea class="edit-textarea" id="edit-summary" placeholder="Write a short bio...">${config.summary || ''}</textarea>
905
+ </div>
906
+
645
907
  <div class="stats">
646
908
  <div class="stat">
647
909
  <div class="stat-num">${followers}</div>
@@ -656,6 +918,12 @@ ${JSON.stringify(actor, null, 2)}
656
918
  <div class="stat-label">Posts</div>
657
919
  </div>
658
920
  </div>
921
+
922
+ <div class="edit-actions">
923
+ <button class="save-btn" onclick="saveProfile()">Save</button>
924
+ <button class="cancel-btn" onclick="toggleEdit()">Cancel</button>
925
+ </div>
926
+
659
927
  <div class="badge">📦 Powered by Fedbox</div>
660
928
  </div>
661
929
 
@@ -671,6 +939,50 @@ ${JSON.stringify(actor, null, 2)}
671
939
  `).join('')}
672
940
  </div>
673
941
  ` : ''}
942
+
943
+ <script>
944
+ let avatarFile = null;
945
+
946
+ function toggleEdit() {
947
+ document.getElementById('profile-card').classList.toggle('editing');
948
+ avatarFile = null;
949
+ }
950
+
951
+ function previewAvatar(input) {
952
+ if (input.files && input.files[0]) {
953
+ avatarFile = input.files[0];
954
+ const reader = new FileReader();
955
+ reader.onload = (e) => {
956
+ const avatar = document.querySelector('.avatar');
957
+ avatar.innerHTML = '<img src="' + e.target.result + '" alt="avatar"><div class="avatar-overlay">Change</div>';
958
+ };
959
+ reader.readAsDataURL(input.files[0]);
960
+ }
961
+ }
962
+
963
+ async function saveProfile() {
964
+ const formData = new FormData();
965
+ formData.append('displayName', document.getElementById('edit-name').value);
966
+ formData.append('summary', document.getElementById('edit-summary').value);
967
+ if (avatarFile) {
968
+ formData.append('avatar', avatarFile);
969
+ }
970
+
971
+ try {
972
+ const res = await fetch('/${config.username}/edit', {
973
+ method: 'POST',
974
+ body: formData
975
+ });
976
+ if (res.ok) {
977
+ location.reload();
978
+ } else {
979
+ alert('Failed to save');
980
+ }
981
+ } catch (err) {
982
+ alert('Error: ' + err.message);
983
+ }
984
+ }
985
+ </script>
674
986
  </body>
675
987
  </html>`
676
988
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedbox",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Zero to Fediverse in 60 seconds",
5
5
  "type": "module",
6
6
  "main": "lib/server.js",
@@ -12,8 +12,8 @@
12
12
  "test": "node --test 'test/*.test.js'"
13
13
  },
14
14
  "dependencies": {
15
- "microfed": "^0.0.13",
16
- "better-sqlite3": "^11.0.0"
15
+ "better-sqlite3": "^11.0.0",
16
+ "microfed": "^0.0.14"
17
17
  },
18
18
  "keywords": [
19
19
  "activitypub",
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Microfed Profile Component
3
+ * Reads JSON-LD data island and renders a profile card
4
+ *
5
+ * Usage: <script src="profile.js"></script>
6
+ * Requires: <script type="application/ld+json" id="profile">...</script>
7
+ */
8
+ (function() {
9
+ 'use strict'
10
+
11
+ function init() {
12
+ // Find JSON-LD data island
13
+ const script = document.querySelector('script[type="application/ld+json"]#profile') ||
14
+ document.querySelector('script[type="application/ld+json"]')
15
+
16
+ if (!script) {
17
+ console.warn('Microfed Profile: No JSON-LD data island found')
18
+ return
19
+ }
20
+
21
+ let actor
22
+ try {
23
+ actor = JSON.parse(script.textContent)
24
+ } catch (e) {
25
+ console.error('Microfed Profile: Invalid JSON-LD', e)
26
+ return
27
+ }
28
+
29
+ // Find or create container
30
+ let container = document.getElementById('microfed-profile')
31
+ if (!container) {
32
+ container = document.createElement('div')
33
+ container.id = 'microfed-profile'
34
+ script.parentNode.insertBefore(container, script.nextSibling)
35
+ }
36
+
37
+ render(container, actor)
38
+ }
39
+
40
+ function render(container, actor) {
41
+ const name = actor.name || actor.preferredUsername || 'Unknown'
42
+ const username = actor.preferredUsername || ''
43
+ const summary = actor.summary || ''
44
+ const icon = actor.icon?.url || actor.icon || ''
45
+ const followers = actor.followers || ''
46
+ const following = actor.following || ''
47
+
48
+ container.innerHTML = `
49
+ <style>
50
+ .mf-profile {
51
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
52
+ max-width: 400px;
53
+ border: 1px solid #e1e4e8;
54
+ border-radius: 8px;
55
+ padding: 20px;
56
+ background: #fff;
57
+ }
58
+ .mf-profile-header {
59
+ display: flex;
60
+ align-items: center;
61
+ gap: 16px;
62
+ margin-bottom: 12px;
63
+ }
64
+ .mf-profile-avatar {
65
+ width: 64px;
66
+ height: 64px;
67
+ border-radius: 50%;
68
+ object-fit: cover;
69
+ background: #e1e4e8;
70
+ }
71
+ .mf-profile-avatar-placeholder {
72
+ width: 64px;
73
+ height: 64px;
74
+ border-radius: 50%;
75
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
76
+ display: flex;
77
+ align-items: center;
78
+ justify-content: center;
79
+ color: white;
80
+ font-size: 24px;
81
+ font-weight: bold;
82
+ }
83
+ .mf-profile-name {
84
+ font-size: 1.25rem;
85
+ font-weight: 600;
86
+ margin: 0;
87
+ color: #24292e;
88
+ }
89
+ .mf-profile-username {
90
+ font-size: 0.875rem;
91
+ color: #586069;
92
+ margin: 2px 0 0 0;
93
+ }
94
+ .mf-profile-summary {
95
+ color: #24292e;
96
+ line-height: 1.5;
97
+ margin: 12px 0;
98
+ }
99
+ .mf-profile-stats {
100
+ display: flex;
101
+ gap: 16px;
102
+ margin-top: 12px;
103
+ padding-top: 12px;
104
+ border-top: 1px solid #e1e4e8;
105
+ }
106
+ .mf-profile-stat {
107
+ color: #586069;
108
+ font-size: 0.875rem;
109
+ text-decoration: none;
110
+ }
111
+ .mf-profile-stat:hover {
112
+ color: #0366d6;
113
+ }
114
+ .mf-profile-stat strong {
115
+ color: #24292e;
116
+ }
117
+ </style>
118
+ <div class="mf-profile">
119
+ <div class="mf-profile-header">
120
+ ${icon
121
+ ? `<img class="mf-profile-avatar" src="${escapeHtml(icon)}" alt="${escapeHtml(name)}">`
122
+ : `<div class="mf-profile-avatar-placeholder">${escapeHtml(name.charAt(0).toUpperCase())}</div>`
123
+ }
124
+ <div>
125
+ <h2 class="mf-profile-name">${escapeHtml(name)}</h2>
126
+ ${username ? `<p class="mf-profile-username">@${escapeHtml(username)}</p>` : ''}
127
+ </div>
128
+ </div>
129
+ ${summary ? `<div class="mf-profile-summary">${summary}</div>` : ''}
130
+ <div class="mf-profile-stats">
131
+ ${followers ? `<a class="mf-profile-stat" href="${escapeHtml(followers)}"><strong>Followers</strong></a>` : ''}
132
+ ${following ? `<a class="mf-profile-stat" href="${escapeHtml(following)}"><strong>Following</strong></a>` : ''}
133
+ </div>
134
+ </div>
135
+ `
136
+ }
137
+
138
+ function escapeHtml(str) {
139
+ if (!str) return ''
140
+ const div = document.createElement('div')
141
+ div.textContent = str
142
+ return div.innerHTML
143
+ }
144
+
145
+ // Initialize when DOM is ready
146
+ if (document.readyState === 'loading') {
147
+ document.addEventListener('DOMContentLoaded', init)
148
+ } else {
149
+ init()
150
+ }
151
+ })()