fedbox 0.0.6 → 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 +21 -1
- package/lib/profile-server.js +427 -0
- package/lib/server.js +300 -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,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,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,18 @@ 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
|
+
return actor
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
/**
|
|
@@ -384,7 +396,7 @@ async function handleRequest(req, res) {
|
|
|
384
396
|
version: '2.1',
|
|
385
397
|
software: {
|
|
386
398
|
name: 'fedbox',
|
|
387
|
-
version: '0.0.
|
|
399
|
+
version: '0.0.7',
|
|
388
400
|
repository: 'https://github.com/micro-fed/fedbox'
|
|
389
401
|
},
|
|
390
402
|
protocols: ['activitypub'],
|
|
@@ -421,6 +433,11 @@ async function handleRequest(req, res) {
|
|
|
421
433
|
}
|
|
422
434
|
}
|
|
423
435
|
|
|
436
|
+
// Profile edit
|
|
437
|
+
if (path === `/${config.username}/edit` && req.method === 'POST') {
|
|
438
|
+
return handleProfileEdit(req, res)
|
|
439
|
+
}
|
|
440
|
+
|
|
424
441
|
// Inbox
|
|
425
442
|
if (path === `/${config.username}/inbox`) {
|
|
426
443
|
return handleInbox(req, res)
|
|
@@ -581,13 +598,131 @@ async function handleInbox(req, res) {
|
|
|
581
598
|
}
|
|
582
599
|
|
|
583
600
|
/**
|
|
584
|
-
*
|
|
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
|
|
585
719
|
*/
|
|
586
720
|
function renderProfile() {
|
|
587
721
|
const followers = getFollowerCount()
|
|
588
722
|
const following = getFollowingCount()
|
|
589
723
|
const posts = getPosts(10)
|
|
590
724
|
const profileUrl = `${getBaseUrl()}/${config.username}`
|
|
725
|
+
const avatarUrl = config.avatar ? `/public/${config.avatar}` : ''
|
|
591
726
|
|
|
592
727
|
return `<!DOCTYPE html>
|
|
593
728
|
<html>
|
|
@@ -616,7 +751,21 @@ ${JSON.stringify(actor, null, 2)}
|
|
|
616
751
|
padding: 2rem;
|
|
617
752
|
text-align: center;
|
|
618
753
|
margin-bottom: 1.5rem;
|
|
754
|
+
position: relative;
|
|
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;
|
|
619
767
|
}
|
|
768
|
+
.edit-btn:hover { background: #5a6fd6; }
|
|
620
769
|
.avatar {
|
|
621
770
|
width: 120px;
|
|
622
771
|
height: 120px;
|
|
@@ -627,7 +776,26 @@ ${JSON.stringify(actor, null, 2)}
|
|
|
627
776
|
align-items: center;
|
|
628
777
|
justify-content: center;
|
|
629
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;
|
|
630
796
|
}
|
|
797
|
+
.editing .avatar-overlay { display: flex; }
|
|
798
|
+
.avatar-input { display: none; }
|
|
631
799
|
h1 { margin: 0 0 0.5rem; }
|
|
632
800
|
.handle { color: #888; margin-bottom: 1rem; }
|
|
633
801
|
.bio { color: #aaa; margin-bottom: 1.5rem; }
|
|
@@ -654,14 +822,88 @@ ${JSON.stringify(actor, null, 2)}
|
|
|
654
822
|
.post-content { margin-bottom: 0.5rem; }
|
|
655
823
|
.post-meta { color: #666; font-size: 0.8rem; }
|
|
656
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; }
|
|
657
879
|
</style>
|
|
658
880
|
</head>
|
|
659
881
|
<body>
|
|
660
|
-
<div class="card">
|
|
661
|
-
<
|
|
662
|
-
|
|
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
|
+
|
|
663
898
|
<p class="handle">@${config.username}@${getDomain()}</p>
|
|
664
|
-
|
|
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
|
+
|
|
665
907
|
<div class="stats">
|
|
666
908
|
<div class="stat">
|
|
667
909
|
<div class="stat-num">${followers}</div>
|
|
@@ -676,6 +918,12 @@ ${JSON.stringify(actor, null, 2)}
|
|
|
676
918
|
<div class="stat-label">Posts</div>
|
|
677
919
|
</div>
|
|
678
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
|
+
|
|
679
927
|
<div class="badge">📦 Powered by Fedbox</div>
|
|
680
928
|
</div>
|
|
681
929
|
|
|
@@ -691,6 +939,50 @@ ${JSON.stringify(actor, null, 2)}
|
|
|
691
939
|
`).join('')}
|
|
692
940
|
</div>
|
|
693
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>
|
|
694
986
|
</body>
|
|
695
987
|
</html>`
|
|
696
988
|
}
|