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 +32 -13
- package/bin/cli.js +214 -23
- package/lib/actions.js +295 -0
- package/lib/server.js +183 -3
- package/package.json +1 -1
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
|
-
- **
|
|
47
|
-
- **
|
|
48
|
-
- **
|
|
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
|
-
*
|
|
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(
|
|
133
|
-
|
|
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:
|
|
142
|
-
Name:
|
|
143
|
-
Port:
|
|
144
|
-
Domain:
|
|
145
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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"
|
|
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
|
}
|