fedbox 0.0.1
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 +99 -0
- package/bin/cli.js +177 -0
- package/lib/server.js +464 -0
- package/lib/store.js +209 -0
- package/package.json +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# 📦 Fedbox
|
|
2
|
+
|
|
3
|
+
**Zero to Fediverse in 60 seconds.**
|
|
4
|
+
|
|
5
|
+
Fedbox is the fastest way to get your own identity on the Fediverse. Run your own ActivityPub server, federate with Mastodon, and own your social presence.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Install
|
|
11
|
+
npm install -g fedbox
|
|
12
|
+
|
|
13
|
+
# Set up your identity
|
|
14
|
+
fedbox init
|
|
15
|
+
|
|
16
|
+
# Start your server
|
|
17
|
+
fedbox start
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
That's it. You're on the Fediverse.
|
|
21
|
+
|
|
22
|
+
## Federation (so Mastodon can find you)
|
|
23
|
+
|
|
24
|
+
To federate with the wider Fediverse, you need a public HTTPS URL. The easiest way:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# In another terminal
|
|
28
|
+
ngrok http 3000
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Copy your ngrok URL (e.g., `abc123.ngrok.io`) and add it to `fedbox.json`:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"domain": "abc123.ngrok.io",
|
|
36
|
+
...
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Restart your server, and you're federated! Search for `@yourname@abc123.ngrok.io` on Mastodon.
|
|
41
|
+
|
|
42
|
+
## What You Get
|
|
43
|
+
|
|
44
|
+
- **Your own identity** — `@you@yourdomain.com`
|
|
45
|
+
- **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
|
+
```
|
|
58
|
+
|
|
59
|
+
## How It Works
|
|
60
|
+
|
|
61
|
+
Fedbox uses [microfed](https://github.com/micro-fed/microfed.org) for ActivityPub primitives:
|
|
62
|
+
|
|
63
|
+
- **Profile** — Your actor/identity
|
|
64
|
+
- **Inbox** — Receive follows, likes, boosts
|
|
65
|
+
- **Outbox** — Your posts
|
|
66
|
+
- **WebFinger** — So others can find you
|
|
67
|
+
|
|
68
|
+
Data is stored in SQLite (`data/fedbox.db`).
|
|
69
|
+
|
|
70
|
+
## Configuration
|
|
71
|
+
|
|
72
|
+
After `fedbox init`, you'll have a `fedbox.json`:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"username": "alice",
|
|
77
|
+
"displayName": "Alice",
|
|
78
|
+
"summary": "Hello, Fediverse!",
|
|
79
|
+
"port": 3000,
|
|
80
|
+
"domain": null,
|
|
81
|
+
"publicKey": "...",
|
|
82
|
+
"privateKey": "..."
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Add `"domain"` for federation with the wider Fediverse.
|
|
87
|
+
|
|
88
|
+
## Requirements
|
|
89
|
+
|
|
90
|
+
- Node.js 18+
|
|
91
|
+
- For federation: ngrok or a public server with HTTPS
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
**Built with [microfed](https://github.com/micro-fed/microfed.org). Happy federating! 📦**
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pubcrawl CLI
|
|
5
|
+
* Zero to Fediverse in 60 seconds
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createInterface } from 'readline'
|
|
9
|
+
import { existsSync, writeFileSync, mkdirSync } from 'fs'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import { generateKeypair } from 'microfed/auth'
|
|
12
|
+
|
|
13
|
+
const rl = createInterface({
|
|
14
|
+
input: process.stdin,
|
|
15
|
+
output: process.stdout
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const ask = (q) => new Promise(resolve => rl.question(q, resolve))
|
|
19
|
+
|
|
20
|
+
const BANNER = `
|
|
21
|
+
╔═══════════════════════════════════════════╗
|
|
22
|
+
║ ║
|
|
23
|
+
║ 📦 FEDBOX ║
|
|
24
|
+
║ Zero to Fediverse in 60 seconds ║
|
|
25
|
+
║ ║
|
|
26
|
+
╚═══════════════════════════════════════════╝
|
|
27
|
+
`
|
|
28
|
+
|
|
29
|
+
const COMMANDS = {
|
|
30
|
+
init: runInit,
|
|
31
|
+
start: runStart,
|
|
32
|
+
status: runStatus,
|
|
33
|
+
help: runHelp
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function main() {
|
|
37
|
+
const command = process.argv[2] || 'help'
|
|
38
|
+
const handler = COMMANDS[command]
|
|
39
|
+
|
|
40
|
+
if (!handler) {
|
|
41
|
+
console.log(`Unknown command: ${command}`)
|
|
42
|
+
runHelp()
|
|
43
|
+
process.exit(1)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await handler()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function runInit() {
|
|
50
|
+
console.log(BANNER)
|
|
51
|
+
|
|
52
|
+
// Check if already initialized
|
|
53
|
+
if (existsSync('fedbox.json')) {
|
|
54
|
+
console.log('⚠️ Already initialized. Delete fedbox.json to start over.\n')
|
|
55
|
+
process.exit(1)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log('Let\'s get you on the Fediverse!\n')
|
|
59
|
+
|
|
60
|
+
// Gather info
|
|
61
|
+
const username = await ask('👤 Username (e.g., alice): ')
|
|
62
|
+
const displayName = await ask('📛 Display name (e.g., Alice): ') || username
|
|
63
|
+
const summary = await ask('📝 Bio (optional): ') || ''
|
|
64
|
+
const port = await ask('🔌 Port (default 3000): ') || '3000'
|
|
65
|
+
|
|
66
|
+
console.log('\n🔐 Generating keypair...')
|
|
67
|
+
const { publicKey, privateKey } = generateKeypair()
|
|
68
|
+
|
|
69
|
+
// Create config
|
|
70
|
+
const config = {
|
|
71
|
+
username: username.toLowerCase().replace(/[^a-z0-9]/g, ''),
|
|
72
|
+
displayName,
|
|
73
|
+
summary,
|
|
74
|
+
port: parseInt(port),
|
|
75
|
+
publicKey,
|
|
76
|
+
privateKey,
|
|
77
|
+
createdAt: new Date().toISOString()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Create data directory
|
|
81
|
+
if (!existsSync('data')) {
|
|
82
|
+
mkdirSync('data')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Save config
|
|
86
|
+
writeFileSync('fedbox.json', JSON.stringify(config, null, 2))
|
|
87
|
+
console.log('✅ Config saved to fedbox.json')
|
|
88
|
+
|
|
89
|
+
console.log(`
|
|
90
|
+
╔═══════════════════════════════════════════╗
|
|
91
|
+
║ ✅ READY! ║
|
|
92
|
+
╚═══════════════════════════════════════════╝
|
|
93
|
+
|
|
94
|
+
Next steps:
|
|
95
|
+
|
|
96
|
+
1. Start your server:
|
|
97
|
+
$ fedbox start
|
|
98
|
+
|
|
99
|
+
2. Expose with ngrok (for federation):
|
|
100
|
+
$ ngrok http ${port}
|
|
101
|
+
|
|
102
|
+
3. Update fedbox.json with your ngrok domain
|
|
103
|
+
|
|
104
|
+
4. Visit your profile:
|
|
105
|
+
http://localhost:${port}/@${config.username}
|
|
106
|
+
|
|
107
|
+
Happy federating! 📦
|
|
108
|
+
`)
|
|
109
|
+
|
|
110
|
+
rl.close()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function runStart() {
|
|
114
|
+
if (!existsSync('fedbox.json')) {
|
|
115
|
+
console.log('❌ Not initialized. Run: fedbox init\n')
|
|
116
|
+
process.exit(1)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log('🚀 Starting server...\n')
|
|
120
|
+
|
|
121
|
+
// Dynamic import to avoid loading before init
|
|
122
|
+
const { startServer } = await import('../lib/server.js')
|
|
123
|
+
await startServer()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function runStatus() {
|
|
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(await import('fs').then(fs =>
|
|
133
|
+
fs.readFileSync('fedbox.json', 'utf8')
|
|
134
|
+
))
|
|
135
|
+
|
|
136
|
+
console.log(`
|
|
137
|
+
╔═══════════════════════════════════════════╗
|
|
138
|
+
║ 📊 FEDBOX STATUS ║
|
|
139
|
+
╚═══════════════════════════════════════════╝
|
|
140
|
+
|
|
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
|
+
`)
|
|
147
|
+
|
|
148
|
+
rl.close()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function runHelp() {
|
|
152
|
+
console.log(`
|
|
153
|
+
${BANNER}
|
|
154
|
+
Usage: fedbox <command>
|
|
155
|
+
|
|
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
|
|
161
|
+
|
|
162
|
+
Quick start:
|
|
163
|
+
$ fedbox init
|
|
164
|
+
$ fedbox start
|
|
165
|
+
|
|
166
|
+
For federation (so Mastodon can find you):
|
|
167
|
+
$ ngrok http 3000
|
|
168
|
+
Then update fedbox.json with your ngrok domain
|
|
169
|
+
`)
|
|
170
|
+
|
|
171
|
+
rl.close()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
main().catch(err => {
|
|
175
|
+
console.error('Error:', err.message)
|
|
176
|
+
process.exit(1)
|
|
177
|
+
})
|
package/lib/server.js
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fedbox Server
|
|
3
|
+
* ActivityPub server using microfed
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createServer } from 'http'
|
|
7
|
+
import { readFileSync, existsSync } from 'fs'
|
|
8
|
+
import { profile, auth, webfinger, outbox } from 'microfed'
|
|
9
|
+
import {
|
|
10
|
+
initStore,
|
|
11
|
+
addFollower,
|
|
12
|
+
removeFollower,
|
|
13
|
+
getFollowers,
|
|
14
|
+
getFollowerCount,
|
|
15
|
+
addFollowing,
|
|
16
|
+
acceptFollowing,
|
|
17
|
+
getFollowingCount,
|
|
18
|
+
saveActivity,
|
|
19
|
+
getActivities,
|
|
20
|
+
savePost,
|
|
21
|
+
getPosts,
|
|
22
|
+
cacheActor,
|
|
23
|
+
getCachedActor
|
|
24
|
+
} from './store.js'
|
|
25
|
+
|
|
26
|
+
let config = null
|
|
27
|
+
let actor = null
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Load configuration
|
|
31
|
+
*/
|
|
32
|
+
function loadConfig() {
|
|
33
|
+
if (!existsSync('fedbox.json')) {
|
|
34
|
+
throw new Error('Not initialized. Run: fedbox init')
|
|
35
|
+
}
|
|
36
|
+
config = JSON.parse(readFileSync('fedbox.json', 'utf8'))
|
|
37
|
+
return config
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the domain (with ngrok support)
|
|
42
|
+
*/
|
|
43
|
+
function getDomain() {
|
|
44
|
+
return config.domain || `localhost:${config.port}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get protocol
|
|
49
|
+
*/
|
|
50
|
+
function getProtocol() {
|
|
51
|
+
return config.domain ? 'https' : 'http'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build actor object
|
|
56
|
+
*/
|
|
57
|
+
function buildActor() {
|
|
58
|
+
const domain = getDomain()
|
|
59
|
+
const protocol = getProtocol()
|
|
60
|
+
const baseUrl = `${protocol}://${domain}`
|
|
61
|
+
|
|
62
|
+
return profile.createActor({
|
|
63
|
+
id: `${baseUrl}/users/${config.username}`,
|
|
64
|
+
username: config.username,
|
|
65
|
+
name: config.displayName,
|
|
66
|
+
summary: config.summary ? `<p>${config.summary}</p>` : '',
|
|
67
|
+
publicKey: config.publicKey,
|
|
68
|
+
sharedInbox: `${baseUrl}/inbox`
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fetch remote actor (with caching)
|
|
74
|
+
*/
|
|
75
|
+
async function fetchActor(id) {
|
|
76
|
+
const cached = getCachedActor(id)
|
|
77
|
+
if (cached) return cached
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const response = await fetch(id, {
|
|
81
|
+
headers: { 'Accept': 'application/activity+json' }
|
|
82
|
+
})
|
|
83
|
+
if (!response.ok) return null
|
|
84
|
+
|
|
85
|
+
const actorData = await response.json()
|
|
86
|
+
cacheActor(actorData)
|
|
87
|
+
return actorData
|
|
88
|
+
} catch {
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Handle incoming activities
|
|
95
|
+
*/
|
|
96
|
+
async function handleActivity(activity) {
|
|
97
|
+
console.log(`📥 ${activity.type} from ${activity.actor}`)
|
|
98
|
+
saveActivity(activity)
|
|
99
|
+
|
|
100
|
+
switch (activity.type) {
|
|
101
|
+
case 'Follow':
|
|
102
|
+
await handleFollow(activity)
|
|
103
|
+
break
|
|
104
|
+
case 'Undo':
|
|
105
|
+
await handleUndo(activity)
|
|
106
|
+
break
|
|
107
|
+
case 'Accept':
|
|
108
|
+
handleAccept(activity)
|
|
109
|
+
break
|
|
110
|
+
case 'Create':
|
|
111
|
+
console.log(` New post: ${activity.object?.content?.slice(0, 50)}...`)
|
|
112
|
+
break
|
|
113
|
+
case 'Like':
|
|
114
|
+
console.log(` ❤️ Liked: ${activity.object}`)
|
|
115
|
+
break
|
|
116
|
+
case 'Announce':
|
|
117
|
+
console.log(` 🔁 Boosted: ${activity.object}`)
|
|
118
|
+
break
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Handle Follow activity
|
|
124
|
+
*/
|
|
125
|
+
async function handleFollow(activity) {
|
|
126
|
+
const followerActor = await fetchActor(activity.actor)
|
|
127
|
+
if (!followerActor) {
|
|
128
|
+
console.log(' Could not fetch follower actor')
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Add to followers
|
|
133
|
+
addFollower(activity.actor, followerActor.inbox)
|
|
134
|
+
console.log(` ✅ New follower: ${followerActor.preferredUsername}`)
|
|
135
|
+
|
|
136
|
+
// Send Accept
|
|
137
|
+
const accept = outbox.createAccept(actor.id, activity)
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await outbox.send({
|
|
141
|
+
activity: accept,
|
|
142
|
+
inbox: followerActor.inbox,
|
|
143
|
+
privateKey: config.privateKey,
|
|
144
|
+
keyId: `${actor.id}#main-key`
|
|
145
|
+
})
|
|
146
|
+
console.log(` 📤 Sent Accept to ${followerActor.inbox}`)
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.log(` ❌ Failed to send Accept: ${err.message}`)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Handle Undo activity
|
|
154
|
+
*/
|
|
155
|
+
async function handleUndo(activity) {
|
|
156
|
+
if (activity.object?.type === 'Follow') {
|
|
157
|
+
removeFollower(activity.actor)
|
|
158
|
+
console.log(` 👋 Unfollowed by ${activity.actor}`)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Handle Accept activity (our follow was accepted)
|
|
164
|
+
*/
|
|
165
|
+
function handleAccept(activity) {
|
|
166
|
+
if (activity.object?.type === 'Follow') {
|
|
167
|
+
acceptFollowing(activity.object.object)
|
|
168
|
+
console.log(` ✅ Follow accepted!`)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Request handler
|
|
174
|
+
*/
|
|
175
|
+
async function handleRequest(req, res) {
|
|
176
|
+
const url = new URL(req.url, `${getProtocol()}://${getDomain()}`)
|
|
177
|
+
const path = url.pathname
|
|
178
|
+
const accept = req.headers.accept || ''
|
|
179
|
+
const isAP = accept.includes('activity+json') || accept.includes('ld+json')
|
|
180
|
+
|
|
181
|
+
console.log(`${req.method} ${path}`)
|
|
182
|
+
|
|
183
|
+
// CORS
|
|
184
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
185
|
+
res.setHeader('Access-Control-Allow-Headers', '*')
|
|
186
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
187
|
+
|
|
188
|
+
if (req.method === 'OPTIONS') {
|
|
189
|
+
res.writeHead(204)
|
|
190
|
+
return res.end()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// WebFinger
|
|
194
|
+
if (path === '/.well-known/webfinger') {
|
|
195
|
+
const resource = url.searchParams.get('resource')
|
|
196
|
+
const parsed = webfinger.parseResource(resource)
|
|
197
|
+
|
|
198
|
+
if (!parsed || parsed.username !== config.username) {
|
|
199
|
+
res.writeHead(404)
|
|
200
|
+
return res.end('Not found')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const response = webfinger.createResponse(
|
|
204
|
+
`${config.username}@${getDomain()}`,
|
|
205
|
+
actor.id,
|
|
206
|
+
{ profileUrl: `${getProtocol()}://${getDomain()}/@${config.username}` }
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
res.setHeader('Content-Type', 'application/jrd+json')
|
|
210
|
+
return res.end(JSON.stringify(response, null, 2))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Actor
|
|
214
|
+
if (path === `/users/${config.username}`) {
|
|
215
|
+
res.setHeader('Content-Type', 'application/activity+json')
|
|
216
|
+
return res.end(JSON.stringify(actor, null, 2))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Inbox
|
|
220
|
+
if (path === `/users/${config.username}/inbox` || path === '/inbox') {
|
|
221
|
+
if (req.method !== 'POST') {
|
|
222
|
+
res.writeHead(405)
|
|
223
|
+
return res.end('Method not allowed')
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const chunks = []
|
|
227
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
228
|
+
const body = Buffer.concat(chunks).toString()
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const activity = JSON.parse(body)
|
|
232
|
+
await handleActivity(activity)
|
|
233
|
+
res.writeHead(202)
|
|
234
|
+
return res.end()
|
|
235
|
+
} catch (err) {
|
|
236
|
+
console.error('Inbox error:', err)
|
|
237
|
+
res.writeHead(400)
|
|
238
|
+
return res.end('Bad request')
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Outbox
|
|
243
|
+
if (path === `/users/${config.username}/outbox`) {
|
|
244
|
+
const posts = getPosts(20)
|
|
245
|
+
const collection = {
|
|
246
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
247
|
+
type: 'OrderedCollection',
|
|
248
|
+
id: `${actor.id}/outbox`,
|
|
249
|
+
totalItems: posts.length,
|
|
250
|
+
orderedItems: posts.map(p => ({
|
|
251
|
+
type: 'Create',
|
|
252
|
+
actor: actor.id,
|
|
253
|
+
object: {
|
|
254
|
+
type: 'Note',
|
|
255
|
+
id: p.id,
|
|
256
|
+
content: p.content,
|
|
257
|
+
published: p.published,
|
|
258
|
+
attributedTo: actor.id
|
|
259
|
+
}
|
|
260
|
+
}))
|
|
261
|
+
}
|
|
262
|
+
res.setHeader('Content-Type', 'application/activity+json')
|
|
263
|
+
return res.end(JSON.stringify(collection, null, 2))
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Followers
|
|
267
|
+
if (path === `/users/${config.username}/followers`) {
|
|
268
|
+
const followers = getFollowers()
|
|
269
|
+
const collection = {
|
|
270
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
271
|
+
type: 'OrderedCollection',
|
|
272
|
+
id: `${actor.id}/followers`,
|
|
273
|
+
totalItems: followers.length,
|
|
274
|
+
orderedItems: followers.map(f => f.actor)
|
|
275
|
+
}
|
|
276
|
+
res.setHeader('Content-Type', 'application/activity+json')
|
|
277
|
+
return res.end(JSON.stringify(collection, null, 2))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Following
|
|
281
|
+
if (path === `/users/${config.username}/following`) {
|
|
282
|
+
const collection = {
|
|
283
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
284
|
+
type: 'OrderedCollection',
|
|
285
|
+
id: `${actor.id}/following`,
|
|
286
|
+
totalItems: getFollowingCount(),
|
|
287
|
+
orderedItems: []
|
|
288
|
+
}
|
|
289
|
+
res.setHeader('Content-Type', 'application/activity+json')
|
|
290
|
+
return res.end(JSON.stringify(collection, null, 2))
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// HTML Profile
|
|
294
|
+
if (path === `/@${config.username}` || (path === `/users/${config.username}` && !isAP)) {
|
|
295
|
+
res.setHeader('Content-Type', 'text/html')
|
|
296
|
+
return res.end(renderProfile())
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Home
|
|
300
|
+
if (path === '/') {
|
|
301
|
+
res.setHeader('Content-Type', 'text/html')
|
|
302
|
+
return res.end(renderHome())
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
res.writeHead(404)
|
|
306
|
+
res.end('Not found')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Render HTML profile
|
|
311
|
+
*/
|
|
312
|
+
function renderProfile() {
|
|
313
|
+
const followers = getFollowerCount()
|
|
314
|
+
const following = getFollowingCount()
|
|
315
|
+
|
|
316
|
+
return `<!DOCTYPE html>
|
|
317
|
+
<html>
|
|
318
|
+
<head>
|
|
319
|
+
<meta charset="utf-8">
|
|
320
|
+
<title>${config.displayName} (@${config.username}@${getDomain()})</title>
|
|
321
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
322
|
+
<style>
|
|
323
|
+
* { box-sizing: border-box; }
|
|
324
|
+
body {
|
|
325
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
326
|
+
max-width: 600px;
|
|
327
|
+
margin: 0 auto;
|
|
328
|
+
padding: 2rem;
|
|
329
|
+
background: #1a1a2e;
|
|
330
|
+
color: #eee;
|
|
331
|
+
min-height: 100vh;
|
|
332
|
+
}
|
|
333
|
+
.card {
|
|
334
|
+
background: #16213e;
|
|
335
|
+
border-radius: 16px;
|
|
336
|
+
padding: 2rem;
|
|
337
|
+
text-align: center;
|
|
338
|
+
}
|
|
339
|
+
.avatar {
|
|
340
|
+
width: 120px;
|
|
341
|
+
height: 120px;
|
|
342
|
+
border-radius: 50%;
|
|
343
|
+
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
344
|
+
margin: 0 auto 1rem;
|
|
345
|
+
display: flex;
|
|
346
|
+
align-items: center;
|
|
347
|
+
justify-content: center;
|
|
348
|
+
font-size: 3rem;
|
|
349
|
+
}
|
|
350
|
+
h1 { margin: 0 0 0.5rem; }
|
|
351
|
+
.handle { color: #888; margin-bottom: 1rem; }
|
|
352
|
+
.bio { color: #aaa; margin-bottom: 1.5rem; }
|
|
353
|
+
.stats { display: flex; justify-content: center; gap: 2rem; }
|
|
354
|
+
.stat { text-align: center; }
|
|
355
|
+
.stat-num { font-size: 1.5rem; font-weight: bold; }
|
|
356
|
+
.stat-label { color: #888; font-size: 0.9rem; }
|
|
357
|
+
.badge {
|
|
358
|
+
display: inline-block;
|
|
359
|
+
background: #667eea;
|
|
360
|
+
color: white;
|
|
361
|
+
padding: 0.5rem 1rem;
|
|
362
|
+
border-radius: 20px;
|
|
363
|
+
margin-top: 1.5rem;
|
|
364
|
+
font-size: 0.9rem;
|
|
365
|
+
}
|
|
366
|
+
</style>
|
|
367
|
+
</head>
|
|
368
|
+
<body>
|
|
369
|
+
<div class="card">
|
|
370
|
+
<div class="avatar">🍺</div>
|
|
371
|
+
<h1>${config.displayName}</h1>
|
|
372
|
+
<p class="handle">@${config.username}@${getDomain()}</p>
|
|
373
|
+
${config.summary ? `<p class="bio">${config.summary}</p>` : ''}
|
|
374
|
+
<div class="stats">
|
|
375
|
+
<div class="stat">
|
|
376
|
+
<div class="stat-num">${followers}</div>
|
|
377
|
+
<div class="stat-label">Followers</div>
|
|
378
|
+
</div>
|
|
379
|
+
<div class="stat">
|
|
380
|
+
<div class="stat-num">${following}</div>
|
|
381
|
+
<div class="stat-label">Following</div>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
<div class="badge">📦 Powered by Fedbox</div>
|
|
385
|
+
</div>
|
|
386
|
+
</body>
|
|
387
|
+
</html>`
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Render home page
|
|
392
|
+
*/
|
|
393
|
+
function renderHome() {
|
|
394
|
+
return `<!DOCTYPE html>
|
|
395
|
+
<html>
|
|
396
|
+
<head>
|
|
397
|
+
<meta charset="utf-8">
|
|
398
|
+
<title>Fedbox</title>
|
|
399
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
400
|
+
<style>
|
|
401
|
+
body {
|
|
402
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
403
|
+
max-width: 600px;
|
|
404
|
+
margin: 0 auto;
|
|
405
|
+
padding: 2rem;
|
|
406
|
+
background: #1a1a2e;
|
|
407
|
+
color: #eee;
|
|
408
|
+
}
|
|
409
|
+
h1 { color: #667eea; }
|
|
410
|
+
a { color: #667eea; }
|
|
411
|
+
code { background: #16213e; padding: 0.2rem 0.4rem; border-radius: 4px; }
|
|
412
|
+
.endpoints { background: #16213e; padding: 1rem; border-radius: 8px; }
|
|
413
|
+
.endpoints li { margin: 0.5rem 0; }
|
|
414
|
+
</style>
|
|
415
|
+
</head>
|
|
416
|
+
<body>
|
|
417
|
+
<h1>📦 Fedbox</h1>
|
|
418
|
+
<p>Your Fediverse server is running!</p>
|
|
419
|
+
|
|
420
|
+
<h2>Your Profile</h2>
|
|
421
|
+
<p><a href="/@${config.username}">@${config.username}@${getDomain()}</a></p>
|
|
422
|
+
|
|
423
|
+
<h2>Endpoints</h2>
|
|
424
|
+
<ul class="endpoints">
|
|
425
|
+
<li><code>/.well-known/webfinger</code> - Discovery</li>
|
|
426
|
+
<li><code>/users/${config.username}</code> - Actor</li>
|
|
427
|
+
<li><code>/users/${config.username}/inbox</code> - Inbox</li>
|
|
428
|
+
<li><code>/users/${config.username}/outbox</code> - Outbox</li>
|
|
429
|
+
<li><code>/users/${config.username}/followers</code> - Followers</li>
|
|
430
|
+
</ul>
|
|
431
|
+
|
|
432
|
+
<h2>Federation</h2>
|
|
433
|
+
<p>To federate with Mastodon, expose this server via ngrok:</p>
|
|
434
|
+
<code>ngrok http ${config.port}</code>
|
|
435
|
+
<p>Then update <code>fedbox.json</code> with your ngrok domain.</p>
|
|
436
|
+
</body>
|
|
437
|
+
</html>`
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Start the server
|
|
442
|
+
*/
|
|
443
|
+
export async function startServer() {
|
|
444
|
+
loadConfig()
|
|
445
|
+
initStore()
|
|
446
|
+
actor = buildActor()
|
|
447
|
+
|
|
448
|
+
const server = createServer(handleRequest)
|
|
449
|
+
|
|
450
|
+
server.listen(config.port, () => {
|
|
451
|
+
console.log(`
|
|
452
|
+
📦 Fedbox is running!
|
|
453
|
+
|
|
454
|
+
Profile: http://localhost:${config.port}/@${config.username}
|
|
455
|
+
Actor: http://localhost:${config.port}/users/${config.username}
|
|
456
|
+
|
|
457
|
+
${config.domain ? ` Federated: https://${config.domain}/@${config.username}` : ' ⚠️ Set "domain" in fedbox.json for federation'}
|
|
458
|
+
|
|
459
|
+
Press Ctrl+C to stop
|
|
460
|
+
`)
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export default { startServer }
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fedbox Store
|
|
3
|
+
* SQLite persistence layer
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import Database from 'better-sqlite3'
|
|
7
|
+
import { existsSync } from 'fs'
|
|
8
|
+
|
|
9
|
+
let db = null
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Initialize the database
|
|
13
|
+
* @param {string} path - Path to SQLite file
|
|
14
|
+
*/
|
|
15
|
+
export function initStore(path = 'data/fedbox.db') {
|
|
16
|
+
db = new Database(path)
|
|
17
|
+
|
|
18
|
+
// Create tables
|
|
19
|
+
db.exec(`
|
|
20
|
+
-- Followers (people following us)
|
|
21
|
+
CREATE TABLE IF NOT EXISTS followers (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
actor TEXT NOT NULL,
|
|
24
|
+
inbox TEXT,
|
|
25
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
-- Following (people we follow)
|
|
29
|
+
CREATE TABLE IF NOT EXISTS following (
|
|
30
|
+
id TEXT PRIMARY KEY,
|
|
31
|
+
actor TEXT NOT NULL,
|
|
32
|
+
accepted INTEGER DEFAULT 0,
|
|
33
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
-- Activities (inbox)
|
|
37
|
+
CREATE TABLE IF NOT EXISTS activities (
|
|
38
|
+
id TEXT PRIMARY KEY,
|
|
39
|
+
type TEXT NOT NULL,
|
|
40
|
+
actor TEXT,
|
|
41
|
+
object TEXT,
|
|
42
|
+
raw TEXT NOT NULL,
|
|
43
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
-- Posts (our outbox)
|
|
47
|
+
CREATE TABLE IF NOT EXISTS posts (
|
|
48
|
+
id TEXT PRIMARY KEY,
|
|
49
|
+
content TEXT NOT NULL,
|
|
50
|
+
in_reply_to TEXT,
|
|
51
|
+
published TEXT DEFAULT CURRENT_TIMESTAMP
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
-- Known actors (cache)
|
|
55
|
+
CREATE TABLE IF NOT EXISTS actors (
|
|
56
|
+
id TEXT PRIMARY KEY,
|
|
57
|
+
data TEXT NOT NULL,
|
|
58
|
+
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
59
|
+
);
|
|
60
|
+
`)
|
|
61
|
+
|
|
62
|
+
return db
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get database instance
|
|
67
|
+
*/
|
|
68
|
+
export function getStore() {
|
|
69
|
+
if (!db) {
|
|
70
|
+
throw new Error('Store not initialized. Call initStore() first.')
|
|
71
|
+
}
|
|
72
|
+
return db
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Followers
|
|
76
|
+
|
|
77
|
+
export function addFollower(actorId, inbox) {
|
|
78
|
+
const stmt = db.prepare(`
|
|
79
|
+
INSERT OR REPLACE INTO followers (id, actor, inbox)
|
|
80
|
+
VALUES (?, ?, ?)
|
|
81
|
+
`)
|
|
82
|
+
stmt.run(actorId, actorId, inbox)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function removeFollower(actorId) {
|
|
86
|
+
const stmt = db.prepare('DELETE FROM followers WHERE id = ?')
|
|
87
|
+
stmt.run(actorId)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function getFollowers() {
|
|
91
|
+
const stmt = db.prepare('SELECT * FROM followers ORDER BY created_at DESC')
|
|
92
|
+
return stmt.all()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function getFollowerCount() {
|
|
96
|
+
const stmt = db.prepare('SELECT COUNT(*) as count FROM followers')
|
|
97
|
+
return stmt.get().count
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Following
|
|
101
|
+
|
|
102
|
+
export function addFollowing(actorId, accepted = false) {
|
|
103
|
+
const stmt = db.prepare(`
|
|
104
|
+
INSERT OR REPLACE INTO following (id, actor, accepted)
|
|
105
|
+
VALUES (?, ?, ?)
|
|
106
|
+
`)
|
|
107
|
+
stmt.run(actorId, actorId, accepted ? 1 : 0)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function acceptFollowing(actorId) {
|
|
111
|
+
const stmt = db.prepare('UPDATE following SET accepted = 1 WHERE id = ?')
|
|
112
|
+
stmt.run(actorId)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function removeFollowing(actorId) {
|
|
116
|
+
const stmt = db.prepare('DELETE FROM following WHERE id = ?')
|
|
117
|
+
stmt.run(actorId)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function getFollowing() {
|
|
121
|
+
const stmt = db.prepare('SELECT * FROM following WHERE accepted = 1 ORDER BY created_at DESC')
|
|
122
|
+
return stmt.all()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function getFollowingCount() {
|
|
126
|
+
const stmt = db.prepare('SELECT COUNT(*) as count FROM following WHERE accepted = 1')
|
|
127
|
+
return stmt.get().count
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Activities
|
|
131
|
+
|
|
132
|
+
export function saveActivity(activity) {
|
|
133
|
+
const stmt = db.prepare(`
|
|
134
|
+
INSERT OR REPLACE INTO activities (id, type, actor, object, raw)
|
|
135
|
+
VALUES (?, ?, ?, ?, ?)
|
|
136
|
+
`)
|
|
137
|
+
stmt.run(
|
|
138
|
+
activity.id,
|
|
139
|
+
activity.type,
|
|
140
|
+
typeof activity.actor === 'string' ? activity.actor : activity.actor?.id,
|
|
141
|
+
typeof activity.object === 'string' ? activity.object : JSON.stringify(activity.object),
|
|
142
|
+
JSON.stringify(activity)
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function getActivities(limit = 20) {
|
|
147
|
+
const stmt = db.prepare('SELECT * FROM activities ORDER BY created_at DESC LIMIT ?')
|
|
148
|
+
return stmt.all(limit).map(row => ({
|
|
149
|
+
...row,
|
|
150
|
+
raw: JSON.parse(row.raw)
|
|
151
|
+
}))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Posts
|
|
155
|
+
|
|
156
|
+
export function savePost(id, content, inReplyTo = null) {
|
|
157
|
+
const stmt = db.prepare(`
|
|
158
|
+
INSERT INTO posts (id, content, in_reply_to)
|
|
159
|
+
VALUES (?, ?, ?)
|
|
160
|
+
`)
|
|
161
|
+
stmt.run(id, content, inReplyTo)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function getPosts(limit = 20) {
|
|
165
|
+
const stmt = db.prepare('SELECT * FROM posts ORDER BY published DESC LIMIT ?')
|
|
166
|
+
return stmt.all(limit)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function getPost(id) {
|
|
170
|
+
const stmt = db.prepare('SELECT * FROM posts WHERE id = ?')
|
|
171
|
+
return stmt.get(id)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Actor cache
|
|
175
|
+
|
|
176
|
+
export function cacheActor(actor) {
|
|
177
|
+
const stmt = db.prepare(`
|
|
178
|
+
INSERT OR REPLACE INTO actors (id, data, fetched_at)
|
|
179
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
180
|
+
`)
|
|
181
|
+
stmt.run(actor.id, JSON.stringify(actor))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function getCachedActor(id) {
|
|
185
|
+
const stmt = db.prepare('SELECT * FROM actors WHERE id = ?')
|
|
186
|
+
const row = stmt.get(id)
|
|
187
|
+
return row ? JSON.parse(row.data) : null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export default {
|
|
191
|
+
initStore,
|
|
192
|
+
getStore,
|
|
193
|
+
addFollower,
|
|
194
|
+
removeFollower,
|
|
195
|
+
getFollowers,
|
|
196
|
+
getFollowerCount,
|
|
197
|
+
addFollowing,
|
|
198
|
+
acceptFollowing,
|
|
199
|
+
removeFollowing,
|
|
200
|
+
getFollowing,
|
|
201
|
+
getFollowingCount,
|
|
202
|
+
saveActivity,
|
|
203
|
+
getActivities,
|
|
204
|
+
savePost,
|
|
205
|
+
getPosts,
|
|
206
|
+
getPost,
|
|
207
|
+
cacheActor,
|
|
208
|
+
getCachedActor
|
|
209
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fedbox",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Zero to Fediverse in 60 seconds",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/server.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"fedbox": "./bin/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node lib/server.js",
|
|
12
|
+
"test": "node --test test/"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"microfed": "^0.0.13",
|
|
16
|
+
"better-sqlite3": "^11.0.0"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"activitypub",
|
|
20
|
+
"fediverse",
|
|
21
|
+
"mastodon",
|
|
22
|
+
"server",
|
|
23
|
+
"federation"
|
|
24
|
+
],
|
|
25
|
+
"author": "Melvin Carvalho",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/micro-fed/fedbox"
|
|
30
|
+
}
|
|
31
|
+
}
|