fedbox 0.0.1 → 0.0.2

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/README.md CHANGED
@@ -15,10 +15,32 @@ fedbox init
15
15
 
16
16
  # Start your server
17
17
  fedbox start
18
+
19
+ # Post something!
20
+ fedbox post "Hello, Fediverse!"
18
21
  ```
19
22
 
20
23
  That's it. You're on the Fediverse.
21
24
 
25
+ ## Commands
26
+
27
+ ```bash
28
+ # Setup
29
+ fedbox init # Set up your identity
30
+ fedbox start # Start the server
31
+ fedbox status # Show your profile info
32
+
33
+ # Social
34
+ fedbox post "text" # Post a message to followers
35
+ fedbox follow @user@domain # Follow someone
36
+ fedbox timeline # View posts from people you follow
37
+ fedbox reply <url> "text" # Reply to a post
38
+ fedbox posts # View your own posts
39
+
40
+ # Help
41
+ fedbox help # Show all commands
42
+ ```
43
+
22
44
  ## Federation (so Mastodon can find you)
23
45
 
24
46
  To federate with the wider Fediverse, you need a public HTTPS URL. The easiest way:
@@ -42,28 +64,25 @@ Restart your server, and you're federated! Search for `@yourname@abc123.ngrok.io
42
64
  ## What You Get
43
65
 
44
66
  - **Your own identity** — `@you@yourdomain.com`
67
+ - **Post from CLI** — `fedbox post "Hello world"`
68
+ - **Follow anyone** — `fedbox follow @user@mastodon.social`
69
+ - **View timeline** — `fedbox timeline`
70
+ - **Reply to posts** — `fedbox reply <url> "Nice!"`
45
71
  - **ActivityPub compatible** — Works with Mastodon, Pleroma, Pixelfed, etc.
46
- - **Persistent storage** — SQLite database for followers, posts, activities
47
- - **Beautiful profile page** — Dark theme, looks great
48
- - **Zero config** — Just answer a few questions
49
-
50
- ## Commands
51
-
52
- ```bash
53
- fedbox init # Set up your identity
54
- fedbox start # Start the server
55
- fedbox status # Show current config
56
- fedbox help # Show help
57
- ```
72
+ - **HTTP Signature verification** — Secure federation
73
+ - **Rate limiting** — Protection against abuse
74
+ - **Persistent storage** — SQLite database
75
+ - **Beautiful profile page** — Dark theme, shows your posts
58
76
 
59
77
  ## How It Works
60
78
 
61
79
  Fedbox uses [microfed](https://github.com/micro-fed/microfed.org) for ActivityPub primitives:
62
80
 
63
81
  - **Profile** — Your actor/identity
64
- - **Inbox** — Receive follows, likes, boosts
82
+ - **Inbox** — Receive follows, likes, boosts, posts
65
83
  - **Outbox** — Your posts
66
84
  - **WebFinger** — So others can find you
85
+ - **HTTP Signatures** — Secure signed requests
67
86
 
68
87
  Data is stored in SQLite (`data/fedbox.db`).
69
88
 
package/bin/cli.js CHANGED
@@ -1,13 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Pubcrawl CLI
4
+ * Fedbox CLI
5
5
  * Zero to Fediverse in 60 seconds
6
6
  */
7
7
 
8
8
  import { createInterface } from 'readline'
9
- import { existsSync, writeFileSync, mkdirSync } from 'fs'
10
- import { join } from 'path'
9
+ import { existsSync, writeFileSync, mkdirSync, readFileSync } from 'fs'
11
10
  import { generateKeypair } from 'microfed/auth'
12
11
 
13
12
  const rl = createInterface({
@@ -30,6 +29,11 @@ const COMMANDS = {
30
29
  init: runInit,
31
30
  start: runStart,
32
31
  status: runStatus,
32
+ post: runPost,
33
+ follow: runFollow,
34
+ timeline: runTimeline,
35
+ reply: runReply,
36
+ posts: runPosts,
33
37
  help: runHelp
34
38
  }
35
39
 
@@ -49,7 +53,6 @@ async function main() {
49
53
  async function runInit() {
50
54
  console.log(BANNER)
51
55
 
52
- // Check if already initialized
53
56
  if (existsSync('fedbox.json')) {
54
57
  console.log('⚠️ Already initialized. Delete fedbox.json to start over.\n')
55
58
  process.exit(1)
@@ -57,7 +60,6 @@ async function runInit() {
57
60
 
58
61
  console.log('Let\'s get you on the Fediverse!\n')
59
62
 
60
- // Gather info
61
63
  const username = await ask('👤 Username (e.g., alice): ')
62
64
  const displayName = await ask('📛 Display name (e.g., Alice): ') || username
63
65
  const summary = await ask('📝 Bio (optional): ') || ''
@@ -66,7 +68,6 @@ async function runInit() {
66
68
  console.log('\n🔐 Generating keypair...')
67
69
  const { publicKey, privateKey } = generateKeypair()
68
70
 
69
- // Create config
70
71
  const config = {
71
72
  username: username.toLowerCase().replace(/[^a-z0-9]/g, ''),
72
73
  displayName,
@@ -77,12 +78,10 @@ async function runInit() {
77
78
  createdAt: new Date().toISOString()
78
79
  }
79
80
 
80
- // Create data directory
81
81
  if (!existsSync('data')) {
82
82
  mkdirSync('data')
83
83
  }
84
84
 
85
- // Save config
86
85
  writeFileSync('fedbox.json', JSON.stringify(config, null, 2))
87
86
  console.log('✅ Config saved to fedbox.json')
88
87
 
@@ -118,7 +117,6 @@ async function runStart() {
118
117
 
119
118
  console.log('🚀 Starting server...\n')
120
119
 
121
- // Dynamic import to avoid loading before init
122
120
  const { startServer } = await import('../lib/server.js')
123
121
  await startServer()
124
122
  }
@@ -129,39 +127,232 @@ async function runStatus() {
129
127
  process.exit(1)
130
128
  }
131
129
 
132
- const config = JSON.parse(await import('fs').then(fs =>
133
- fs.readFileSync('fedbox.json', 'utf8')
134
- ))
130
+ const config = JSON.parse(readFileSync('fedbox.json', 'utf8'))
131
+
132
+ // Get follower/following counts
133
+ let followers = 0, following = 0
134
+ try {
135
+ const { initStore, getFollowerCount, getFollowingCount } = await import('../lib/store.js')
136
+ initStore()
137
+ followers = getFollowerCount()
138
+ following = getFollowingCount()
139
+ } catch {}
135
140
 
136
141
  console.log(`
137
142
  ╔═══════════════════════════════════════════╗
138
143
  ║ 📊 FEDBOX STATUS ║
139
144
  ╚═══════════════════════════════════════════╝
140
145
 
141
- Username: @${config.username}
142
- Name: ${config.displayName}
143
- Port: ${config.port}
144
- Domain: ${config.domain || '(not set - run with ngrok)'}
145
- Created: ${config.createdAt}
146
+ Username: @${config.username}
147
+ Name: ${config.displayName}
148
+ Port: ${config.port}
149
+ Domain: ${config.domain || '(not set - run with ngrok)'}
150
+ Followers: ${followers}
151
+ Following: ${following}
152
+ Created: ${config.createdAt}
153
+ `)
154
+
155
+ rl.close()
156
+ }
157
+
158
+ async function runPost() {
159
+ if (!existsSync('fedbox.json')) {
160
+ console.log('❌ Not initialized. Run: fedbox init\n')
161
+ process.exit(1)
162
+ }
163
+
164
+ const content = process.argv[3]
165
+ if (!content) {
166
+ console.log('Usage: fedbox post "Your message here"')
167
+ process.exit(1)
168
+ }
169
+
170
+ const { post } = await import('../lib/actions.js')
171
+
172
+ console.log('📝 Creating post...')
173
+ const result = await post(content)
174
+
175
+ console.log(`
176
+ ✅ Posted!
177
+
178
+ ID: ${result.noteId}
179
+ Content: ${content}
180
+ Delivered to: ${result.delivered.success} followers (${result.delivered.failed} failed)
181
+ `)
182
+
183
+ rl.close()
184
+ }
185
+
186
+ async function runFollow() {
187
+ if (!existsSync('fedbox.json')) {
188
+ console.log('❌ Not initialized. Run: fedbox init\n')
189
+ process.exit(1)
190
+ }
191
+
192
+ const handle = process.argv[3]
193
+ if (!handle) {
194
+ console.log('Usage: fedbox follow @user@domain')
195
+ process.exit(1)
196
+ }
197
+
198
+ const { follow } = await import('../lib/actions.js')
199
+
200
+ try {
201
+ const result = await follow(handle)
202
+ console.log(`
203
+ ✅ Follow request sent!
204
+
205
+ User: ${result.actor.preferredUsername || result.actor.name}
206
+ Actor: ${result.actor.id}
207
+
208
+ Waiting for them to accept...
209
+ `)
210
+ } catch (err) {
211
+ console.log(`❌ ${err.message}`)
212
+ process.exit(1)
213
+ }
214
+
215
+ rl.close()
216
+ }
217
+
218
+ async function runTimeline() {
219
+ if (!existsSync('fedbox.json')) {
220
+ console.log('❌ Not initialized. Run: fedbox init\n')
221
+ process.exit(1)
222
+ }
223
+
224
+ const { timeline } = await import('../lib/actions.js')
225
+ const posts = timeline(20)
226
+
227
+ if (posts.length === 0) {
228
+ console.log(`
229
+ 📭 Your timeline is empty.
230
+
231
+ Follow some people with: fedbox follow @user@domain
232
+ `)
233
+ rl.close()
234
+ return
235
+ }
236
+
237
+ console.log(`
238
+ ╔═══════════════════════════════════════════╗
239
+ ║ 📰 TIMELINE ║
240
+ ╚═══════════════════════════════════════════╝
241
+ `)
242
+
243
+ for (const post of posts) {
244
+ const author = post.author?.split('/').pop() || 'unknown'
245
+ const content = post.content
246
+ .replace(/<[^>]*>/g, '') // Strip HTML
247
+ .slice(0, 200)
248
+ const date = new Date(post.published).toLocaleString()
249
+
250
+ console.log(`┌─ @${author} · ${date}`)
251
+ console.log(`│ ${content}`)
252
+ if (post.inReplyTo) {
253
+ console.log(`│ ↩️ Reply to: ${post.inReplyTo}`)
254
+ }
255
+ console.log(`└─ ${post.id}`)
256
+ console.log()
257
+ }
258
+
259
+ rl.close()
260
+ }
261
+
262
+ async function runReply() {
263
+ if (!existsSync('fedbox.json')) {
264
+ console.log('❌ Not initialized. Run: fedbox init\n')
265
+ process.exit(1)
266
+ }
267
+
268
+ const postUrl = process.argv[3]
269
+ const content = process.argv[4]
270
+
271
+ if (!postUrl || !content) {
272
+ console.log('Usage: fedbox reply <post-url> "Your reply"')
273
+ process.exit(1)
274
+ }
275
+
276
+ const { reply } = await import('../lib/actions.js')
277
+
278
+ console.log('💬 Sending reply...')
279
+ const result = await reply(postUrl, content)
280
+
281
+ console.log(`
282
+ ✅ Reply sent!
283
+
284
+ ID: ${result.noteId}
285
+ In reply to: ${postUrl}
286
+ Delivered to: ${result.delivered.success} inboxes
287
+ `)
288
+
289
+ rl.close()
290
+ }
291
+
292
+ async function runPosts() {
293
+ if (!existsSync('fedbox.json')) {
294
+ console.log('❌ Not initialized. Run: fedbox init\n')
295
+ process.exit(1)
296
+ }
297
+
298
+ const { myPosts } = await import('../lib/actions.js')
299
+ const posts = myPosts(20)
300
+
301
+ if (posts.length === 0) {
302
+ console.log(`
303
+ 📭 You haven't posted anything yet.
304
+
305
+ Create a post with: fedbox post "Hello, Fediverse!"
306
+ `)
307
+ rl.close()
308
+ return
309
+ }
310
+
311
+ console.log(`
312
+ ╔═══════════════════════════════════════════╗
313
+ ║ 📝 YOUR POSTS ║
314
+ ╚═══════════════════════════════════════════╝
146
315
  `)
147
316
 
317
+ for (const post of posts) {
318
+ const date = new Date(post.published).toLocaleString()
319
+ console.log(`┌─ ${date}`)
320
+ console.log(`│ ${post.content}`)
321
+ if (post.in_reply_to) {
322
+ console.log(`│ ↩️ Reply to: ${post.in_reply_to}`)
323
+ }
324
+ console.log(`└─ ${post.id}`)
325
+ console.log()
326
+ }
327
+
148
328
  rl.close()
149
329
  }
150
330
 
151
331
  function runHelp() {
152
332
  console.log(`
153
333
  ${BANNER}
154
- Usage: fedbox <command>
334
+ Usage: fedbox <command> [args]
335
+
336
+ Setup:
337
+ init Set up a new Fediverse identity
338
+ start Start the server
339
+ status Show current configuration
340
+
341
+ Social:
342
+ post "text" Post a message to your followers
343
+ follow @user@dom Follow a remote user
344
+ timeline View posts from people you follow
345
+ reply <url> "text" Reply to a post
346
+ posts View your own posts
155
347
 
156
- Commands:
157
- init Set up a new Fediverse identity
158
- start Start the server
159
- status Show current configuration
160
- help Show this help
348
+ Other:
349
+ help Show this help
161
350
 
162
351
  Quick start:
163
352
  $ fedbox init
164
353
  $ fedbox start
354
+ $ fedbox post "Hello, Fediverse!"
355
+ $ fedbox follow @user@mastodon.social
165
356
 
166
357
  For federation (so Mastodon can find you):
167
358
  $ ngrok http 3000
package/lib/actions.js ADDED
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Fedbox Actions
3
+ * User actions: post, follow, reply, timeline
4
+ */
5
+
6
+ import { readFileSync } from 'fs'
7
+ import { outbox, webfinger } from 'microfed'
8
+ import {
9
+ initStore,
10
+ savePost,
11
+ getPosts,
12
+ getPost,
13
+ getFollowers,
14
+ getFollowing,
15
+ addFollowing,
16
+ getActivities,
17
+ cacheActor,
18
+ getCachedActor
19
+ } from './store.js'
20
+
21
+ let config = null
22
+
23
+ /**
24
+ * Load config
25
+ */
26
+ function loadConfig() {
27
+ config = JSON.parse(readFileSync('fedbox.json', 'utf8'))
28
+ return config
29
+ }
30
+
31
+ /**
32
+ * Get actor URL
33
+ */
34
+ function getActorUrl() {
35
+ const domain = config.domain || `localhost:${config.port}`
36
+ const protocol = config.domain ? 'https' : 'http'
37
+ return `${protocol}://${domain}/users/${config.username}`
38
+ }
39
+
40
+ /**
41
+ * Fetch remote actor
42
+ */
43
+ async function fetchActor(id) {
44
+ const cached = getCachedActor(id)
45
+ if (cached) return cached
46
+
47
+ try {
48
+ const response = await fetch(id, {
49
+ headers: { 'Accept': 'application/activity+json' }
50
+ })
51
+ if (!response.ok) return null
52
+ const actor = await response.json()
53
+ cacheActor(actor)
54
+ return actor
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Post a note to followers
62
+ */
63
+ export async function post(content, inReplyTo = null) {
64
+ loadConfig()
65
+ initStore()
66
+
67
+ const actorUrl = getActorUrl()
68
+ const noteId = `${actorUrl}/posts/${Date.now()}`
69
+
70
+ // Create the Note
71
+ const note = outbox.createNote(actorUrl, content, {
72
+ id: noteId,
73
+ inReplyTo,
74
+ to: ['https://www.w3.org/ns/activitystreams#Public'],
75
+ cc: [`${actorUrl}/followers`]
76
+ })
77
+
78
+ // Wrap in Create activity
79
+ const create = {
80
+ '@context': 'https://www.w3.org/ns/activitystreams',
81
+ type: 'Create',
82
+ id: `${noteId}/activity`,
83
+ actor: actorUrl,
84
+ published: new Date().toISOString(),
85
+ to: note.to,
86
+ cc: note.cc,
87
+ object: note
88
+ }
89
+
90
+ // Save to local posts
91
+ savePost(noteId, content, inReplyTo)
92
+
93
+ // Deliver to all followers
94
+ const followers = getFollowers()
95
+ const results = { success: 0, failed: 0 }
96
+
97
+ for (const follower of followers) {
98
+ if (!follower.inbox) continue
99
+ try {
100
+ await outbox.send({
101
+ activity: create,
102
+ inbox: follower.inbox,
103
+ privateKey: config.privateKey,
104
+ keyId: `${actorUrl}#main-key`
105
+ })
106
+ results.success++
107
+ } catch (err) {
108
+ results.failed++
109
+ }
110
+ }
111
+
112
+ return { noteId, note, delivered: results }
113
+ }
114
+
115
+ /**
116
+ * Follow a remote user
117
+ */
118
+ export async function follow(handle) {
119
+ loadConfig()
120
+ initStore()
121
+
122
+ // Parse handle (@user@domain or user@domain)
123
+ const cleanHandle = handle.replace(/^@/, '')
124
+ const [username, domain] = cleanHandle.split('@')
125
+
126
+ if (!username || !domain) {
127
+ throw new Error('Invalid handle. Use format: @user@domain or user@domain')
128
+ }
129
+
130
+ // Resolve via WebFinger
131
+ console.log(`🔍 Looking up ${cleanHandle}...`)
132
+ const resolved = await webfinger.resolve(username, domain)
133
+
134
+ if (!resolved) {
135
+ throw new Error(`Could not find ${cleanHandle}`)
136
+ }
137
+
138
+ // Fetch the actor
139
+ console.log(`📥 Fetching actor...`)
140
+ const remoteActor = await fetchActor(resolved.actorId)
141
+
142
+ if (!remoteActor) {
143
+ throw new Error(`Could not fetch actor: ${resolved.actorId}`)
144
+ }
145
+
146
+ const actorUrl = getActorUrl()
147
+ const inbox = remoteActor.inbox
148
+
149
+ // Create Follow activity
150
+ const followActivity = outbox.createFollow(actorUrl, remoteActor.id)
151
+
152
+ // Send Follow
153
+ console.log(`📤 Sending Follow to ${inbox}...`)
154
+ await outbox.send({
155
+ activity: followActivity,
156
+ inbox,
157
+ privateKey: config.privateKey,
158
+ keyId: `${actorUrl}#main-key`
159
+ })
160
+
161
+ // Save to following (pending acceptance)
162
+ addFollowing(remoteActor.id, false)
163
+
164
+ return {
165
+ actor: remoteActor,
166
+ followActivity
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Reply to a post
172
+ */
173
+ export async function reply(postUrl, content) {
174
+ loadConfig()
175
+ initStore()
176
+
177
+ // Fetch the original post to get the author
178
+ let originalAuthor = null
179
+ try {
180
+ const response = await fetch(postUrl, {
181
+ headers: { 'Accept': 'application/activity+json' }
182
+ })
183
+ if (response.ok) {
184
+ const post = await response.json()
185
+ originalAuthor = post.attributedTo
186
+ }
187
+ } catch {
188
+ // Continue without original author
189
+ }
190
+
191
+ const actorUrl = getActorUrl()
192
+ const noteId = `${actorUrl}/posts/${Date.now()}`
193
+
194
+ // Create the reply Note
195
+ const note = outbox.createNote(actorUrl, content, {
196
+ id: noteId,
197
+ inReplyTo: postUrl,
198
+ to: ['https://www.w3.org/ns/activitystreams#Public'],
199
+ cc: [`${actorUrl}/followers`, originalAuthor].filter(Boolean)
200
+ })
201
+
202
+ // Wrap in Create activity
203
+ const create = {
204
+ '@context': 'https://www.w3.org/ns/activitystreams',
205
+ type: 'Create',
206
+ id: `${noteId}/activity`,
207
+ actor: actorUrl,
208
+ published: new Date().toISOString(),
209
+ to: note.to,
210
+ cc: note.cc,
211
+ object: note
212
+ }
213
+
214
+ // Save locally
215
+ savePost(noteId, content, postUrl)
216
+
217
+ // Collect inboxes to deliver to
218
+ const inboxes = new Set()
219
+
220
+ // Add all followers
221
+ const followers = getFollowers()
222
+ for (const f of followers) {
223
+ if (f.inbox) inboxes.add(f.inbox)
224
+ }
225
+
226
+ // Add original author's inbox
227
+ if (originalAuthor) {
228
+ const authorActor = await fetchActor(originalAuthor)
229
+ if (authorActor?.inbox) {
230
+ inboxes.add(authorActor.inbox)
231
+ }
232
+ }
233
+
234
+ // Deliver
235
+ const results = { success: 0, failed: 0 }
236
+ for (const inbox of inboxes) {
237
+ try {
238
+ await outbox.send({
239
+ activity: create,
240
+ inbox,
241
+ privateKey: config.privateKey,
242
+ keyId: `${actorUrl}#main-key`
243
+ })
244
+ results.success++
245
+ } catch {
246
+ results.failed++
247
+ }
248
+ }
249
+
250
+ return { noteId, note, delivered: results }
251
+ }
252
+
253
+ /**
254
+ * Get timeline (posts from people we follow + mentions)
255
+ */
256
+ export function timeline(limit = 20) {
257
+ loadConfig()
258
+ initStore()
259
+
260
+ // Get Create activities from inbox
261
+ const activities = getActivities(100)
262
+
263
+ const posts = activities
264
+ .filter(a => a.type === 'Create' && a.raw?.object)
265
+ .map(a => {
266
+ const obj = a.raw.object
267
+ return {
268
+ id: obj.id || a.id,
269
+ author: typeof a.raw.actor === 'string' ? a.raw.actor : a.raw.actor?.id,
270
+ content: obj.content || '',
271
+ published: obj.published || a.created_at,
272
+ inReplyTo: obj.inReplyTo
273
+ }
274
+ })
275
+ .slice(0, limit)
276
+
277
+ return posts
278
+ }
279
+
280
+ /**
281
+ * Get our own posts
282
+ */
283
+ export function myPosts(limit = 20) {
284
+ loadConfig()
285
+ initStore()
286
+ return getPosts(limit)
287
+ }
288
+
289
+ export default {
290
+ post,
291
+ follow,
292
+ reply,
293
+ timeline,
294
+ myPosts
295
+ }
package/lib/server.js CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  getActivities,
20
20
  savePost,
21
21
  getPosts,
22
+ getPost,
22
23
  cacheActor,
23
24
  getCachedActor
24
25
  } from './store.js'
@@ -26,6 +27,11 @@ import {
26
27
  let config = null
27
28
  let actor = null
28
29
 
30
+ // Rate limiting: track requests per IP
31
+ const rateLimits = new Map()
32
+ const RATE_LIMIT_WINDOW = 60000 // 1 minute
33
+ const RATE_LIMIT_MAX = 100 // max requests per window
34
+
29
35
  /**
30
36
  * Load configuration
31
37
  */
@@ -69,6 +75,41 @@ function buildActor() {
69
75
  })
70
76
  }
71
77
 
78
+ /**
79
+ * Check rate limit
80
+ */
81
+ function checkRateLimit(ip) {
82
+ const now = Date.now()
83
+ const record = rateLimits.get(ip)
84
+
85
+ if (!record || now - record.start > RATE_LIMIT_WINDOW) {
86
+ rateLimits.set(ip, { start: now, count: 1 })
87
+ return true
88
+ }
89
+
90
+ record.count++
91
+ if (record.count > RATE_LIMIT_MAX) {
92
+ return false
93
+ }
94
+
95
+ return true
96
+ }
97
+
98
+ /**
99
+ * Clean old rate limit entries (run periodically)
100
+ */
101
+ function cleanRateLimits() {
102
+ const now = Date.now()
103
+ for (const [ip, record] of rateLimits) {
104
+ if (now - record.start > RATE_LIMIT_WINDOW) {
105
+ rateLimits.delete(ip)
106
+ }
107
+ }
108
+ }
109
+
110
+ // Clean rate limits every minute
111
+ setInterval(cleanRateLimits, RATE_LIMIT_WINDOW)
112
+
72
113
  /**
73
114
  * Fetch remote actor (with caching)
74
115
  */
@@ -90,6 +131,66 @@ async function fetchActor(id) {
90
131
  }
91
132
  }
92
133
 
134
+ /**
135
+ * Verify HTTP signature on incoming request
136
+ */
137
+ async function verifySignature(req, body) {
138
+ const signature = req.headers['signature']
139
+ if (!signature) {
140
+ return { valid: false, reason: 'No signature header' }
141
+ }
142
+
143
+ // Parse signature header
144
+ const sigParts = {}
145
+ signature.split(',').forEach(part => {
146
+ const [key, ...rest] = part.split('=')
147
+ sigParts[key.trim()] = rest.join('=').replace(/^"|"$/g, '')
148
+ })
149
+
150
+ const keyId = sigParts.keyId
151
+ if (!keyId) {
152
+ return { valid: false, reason: 'No keyId in signature' }
153
+ }
154
+
155
+ // Extract actor ID from keyId (usually actorId#main-key)
156
+ const actorId = keyId.replace(/#.*$/, '')
157
+
158
+ // Fetch the actor to get their public key
159
+ const remoteActor = await fetchActor(actorId)
160
+ if (!remoteActor) {
161
+ return { valid: false, reason: `Could not fetch actor: ${actorId}` }
162
+ }
163
+
164
+ const publicKeyPem = remoteActor.publicKey?.publicKeyPem
165
+ if (!publicKeyPem) {
166
+ return { valid: false, reason: 'Actor has no public key' }
167
+ }
168
+
169
+ // Build the signing string
170
+ const headers = sigParts.headers?.split(' ') || ['(request-target)', 'host', 'date']
171
+ const signingParts = headers.map(header => {
172
+ if (header === '(request-target)') {
173
+ return `(request-target): ${req.method.toLowerCase()} ${req.url}`
174
+ }
175
+ if (header === 'digest' && body) {
176
+ // Recalculate digest for comparison
177
+ const crypto = await import('crypto')
178
+ const digest = crypto.createHash('sha256').update(body).digest('base64')
179
+ return `digest: SHA-256=${digest}`
180
+ }
181
+ return `${header}: ${req.headers[header.toLowerCase()] || ''}`
182
+ })
183
+ const signingString = signingParts.join('\n')
184
+
185
+ // Verify the signature
186
+ try {
187
+ const isValid = auth.verify(signingString, sigParts.signature, publicKeyPem)
188
+ return { valid: isValid, actor: remoteActor }
189
+ } catch (err) {
190
+ return { valid: false, reason: `Verification error: ${err.message}` }
191
+ }
192
+ }
193
+
93
194
  /**
94
195
  * Handle incoming activities
95
196
  */
@@ -108,7 +209,7 @@ async function handleActivity(activity) {
108
209
  handleAccept(activity)
109
210
  break
110
211
  case 'Create':
111
- console.log(` New post: ${activity.object?.content?.slice(0, 50)}...`)
212
+ console.log(` 📝 New post: ${activity.object?.content?.slice(0, 50)}...`)
112
213
  break
113
214
  case 'Like':
114
215
  console.log(` ❤️ Liked: ${activity.object}`)
@@ -173,6 +274,18 @@ function handleAccept(activity) {
173
274
  * Request handler
174
275
  */
175
276
  async function handleRequest(req, res) {
277
+ // Get client IP
278
+ const ip = req.headers['x-forwarded-for']?.split(',')[0] ||
279
+ req.socket.remoteAddress ||
280
+ 'unknown'
281
+
282
+ // Check rate limit
283
+ if (!checkRateLimit(ip)) {
284
+ console.log(`🚫 Rate limited: ${ip}`)
285
+ res.writeHead(429, { 'Retry-After': '60' })
286
+ return res.end('Too many requests')
287
+ }
288
+
176
289
  const url = new URL(req.url, `${getProtocol()}://${getDomain()}`)
177
290
  const path = url.pathname
178
291
  const accept = req.headers.accept || ''
@@ -216,6 +329,34 @@ async function handleRequest(req, res) {
216
329
  return res.end(JSON.stringify(actor, null, 2))
217
330
  }
218
331
 
332
+ // Individual post
333
+ const postMatch = path.match(/^\/users\/[^/]+\/posts\/(\d+)$/)
334
+ if (postMatch) {
335
+ const postId = `${getProtocol()}://${getDomain()}${path}`
336
+ const post = getPost(postId)
337
+ if (!post) {
338
+ res.writeHead(404)
339
+ return res.end('Not found')
340
+ }
341
+
342
+ const note = {
343
+ '@context': 'https://www.w3.org/ns/activitystreams',
344
+ type: 'Note',
345
+ id: post.id,
346
+ attributedTo: actor.id,
347
+ content: post.content,
348
+ published: post.published,
349
+ to: ['https://www.w3.org/ns/activitystreams#Public'],
350
+ cc: [`${actor.id}/followers`]
351
+ }
352
+ if (post.in_reply_to) {
353
+ note.inReplyTo = post.in_reply_to
354
+ }
355
+
356
+ res.setHeader('Content-Type', 'application/activity+json')
357
+ return res.end(JSON.stringify(note, null, 2))
358
+ }
359
+
219
360
  // Inbox
220
361
  if (path === `/users/${config.username}/inbox` || path === '/inbox') {
221
362
  if (req.method !== 'POST') {
@@ -227,6 +368,15 @@ async function handleRequest(req, res) {
227
368
  for await (const chunk of req) chunks.push(chunk)
228
369
  const body = Buffer.concat(chunks).toString()
229
370
 
371
+ // Verify signature (log warning but don't reject for now - some servers don't sign)
372
+ const sigResult = await verifySignature(req, body)
373
+ if (!sigResult.valid) {
374
+ console.log(` ⚠️ Signature: ${sigResult.reason}`)
375
+ // Continue anyway - strict mode would return 401 here
376
+ } else {
377
+ console.log(` 🔐 Signature verified`)
378
+ }
379
+
230
380
  try {
231
381
  const activity = JSON.parse(body)
232
382
  await handleActivity(activity)
@@ -250,12 +400,16 @@ async function handleRequest(req, res) {
250
400
  orderedItems: posts.map(p => ({
251
401
  type: 'Create',
252
402
  actor: actor.id,
403
+ published: p.published,
253
404
  object: {
254
405
  type: 'Note',
255
406
  id: p.id,
256
407
  content: p.content,
257
408
  published: p.published,
258
- attributedTo: actor.id
409
+ attributedTo: actor.id,
410
+ to: ['https://www.w3.org/ns/activitystreams#Public'],
411
+ cc: [`${actor.id}/followers`],
412
+ ...(p.in_reply_to ? { inReplyTo: p.in_reply_to } : {})
259
413
  }
260
414
  }))
261
415
  }
@@ -312,6 +466,7 @@ async function handleRequest(req, res) {
312
466
  function renderProfile() {
313
467
  const followers = getFollowerCount()
314
468
  const following = getFollowingCount()
469
+ const posts = getPosts(10)
315
470
 
316
471
  return `<!DOCTYPE html>
317
472
  <html>
@@ -335,6 +490,7 @@ function renderProfile() {
335
490
  border-radius: 16px;
336
491
  padding: 2rem;
337
492
  text-align: center;
493
+ margin-bottom: 1.5rem;
338
494
  }
339
495
  .avatar {
340
496
  width: 120px;
@@ -363,11 +519,20 @@ function renderProfile() {
363
519
  margin-top: 1.5rem;
364
520
  font-size: 0.9rem;
365
521
  }
522
+ .posts { margin-top: 1rem; }
523
+ .post {
524
+ background: #16213e;
525
+ border-radius: 12px;
526
+ padding: 1rem;
527
+ margin-bottom: 1rem;
528
+ }
529
+ .post-content { margin-bottom: 0.5rem; }
530
+ .post-meta { color: #666; font-size: 0.8rem; }
366
531
  </style>
367
532
  </head>
368
533
  <body>
369
534
  <div class="card">
370
- <div class="avatar">🍺</div>
535
+ <div class="avatar">📦</div>
371
536
  <h1>${config.displayName}</h1>
372
537
  <p class="handle">@${config.username}@${getDomain()}</p>
373
538
  ${config.summary ? `<p class="bio">${config.summary}</p>` : ''}
@@ -380,9 +545,24 @@ function renderProfile() {
380
545
  <div class="stat-num">${following}</div>
381
546
  <div class="stat-label">Following</div>
382
547
  </div>
548
+ <div class="stat">
549
+ <div class="stat-num">${posts.length}</div>
550
+ <div class="stat-label">Posts</div>
551
+ </div>
383
552
  </div>
384
553
  <div class="badge">📦 Powered by Fedbox</div>
385
554
  </div>
555
+
556
+ ${posts.length > 0 ? `
557
+ <div class="posts">
558
+ ${posts.map(p => `
559
+ <div class="post">
560
+ <div class="post-content">${p.content}</div>
561
+ <div class="post-meta">${new Date(p.published).toLocaleString()}</div>
562
+ </div>
563
+ `).join('')}
564
+ </div>
565
+ ` : ''}
386
566
  </body>
387
567
  </html>`
388
568
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedbox",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Zero to Fediverse in 60 seconds",
5
5
  "type": "module",
6
6
  "main": "lib/server.js",