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 +21 -1
- package/lib/profile-server.js +427 -0
- package/lib/server.js +321 -9
- package/package.json +3 -3
- package/public/profile.js +151 -0
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
<
|
|
642
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
16
|
-
"
|
|
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
|
+
})()
|