fedbox 0.0.1 → 0.0.4
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/QUICKSTART.md +90 -0
- package/README.md +32 -13
- package/bin/cli.js +252 -23
- package/lib/actions.js +312 -0
- package/lib/server.js +422 -64
- package/package.json +1 -1
package/lib/actions.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
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 base URL
|
|
33
|
+
*/
|
|
34
|
+
function getBaseUrl() {
|
|
35
|
+
const domain = config.domain || `localhost:${config.port}`
|
|
36
|
+
const protocol = config.domain ? 'https' : 'http'
|
|
37
|
+
return `${protocol}://${domain}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get profile URL (the document)
|
|
42
|
+
*/
|
|
43
|
+
function getProfileUrl() {
|
|
44
|
+
return `${getBaseUrl()}/${config.username}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get actor URL (WebID with #me fragment)
|
|
49
|
+
*/
|
|
50
|
+
function getActorUrl() {
|
|
51
|
+
return `${getProfileUrl()}#me`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fetch remote actor
|
|
56
|
+
*/
|
|
57
|
+
async function fetchActor(id) {
|
|
58
|
+
const cached = getCachedActor(id)
|
|
59
|
+
if (cached) return cached
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const response = await fetch(id, {
|
|
63
|
+
headers: { 'Accept': 'application/activity+json' }
|
|
64
|
+
})
|
|
65
|
+
if (!response.ok) return null
|
|
66
|
+
const actor = await response.json()
|
|
67
|
+
cacheActor(actor)
|
|
68
|
+
return actor
|
|
69
|
+
} catch {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Post a note to followers
|
|
76
|
+
*/
|
|
77
|
+
export async function post(content, inReplyTo = null) {
|
|
78
|
+
loadConfig()
|
|
79
|
+
initStore()
|
|
80
|
+
|
|
81
|
+
const actorUrl = getActorUrl()
|
|
82
|
+
const profileUrl = getProfileUrl()
|
|
83
|
+
const noteId = `${profileUrl}/posts/${Date.now()}`
|
|
84
|
+
|
|
85
|
+
// Create the Note
|
|
86
|
+
const note = outbox.createNote(actorUrl, content, {
|
|
87
|
+
id: noteId,
|
|
88
|
+
inReplyTo,
|
|
89
|
+
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
90
|
+
cc: [`${profileUrl}/followers`]
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// Wrap in Create activity
|
|
94
|
+
const create = {
|
|
95
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
96
|
+
type: 'Create',
|
|
97
|
+
id: `${noteId}/activity`,
|
|
98
|
+
actor: actorUrl,
|
|
99
|
+
published: new Date().toISOString(),
|
|
100
|
+
to: note.to,
|
|
101
|
+
cc: note.cc,
|
|
102
|
+
object: note
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Save to local posts
|
|
106
|
+
savePost(noteId, content, inReplyTo)
|
|
107
|
+
|
|
108
|
+
// Deliver to all followers
|
|
109
|
+
const followers = getFollowers()
|
|
110
|
+
const results = { success: 0, failed: 0 }
|
|
111
|
+
|
|
112
|
+
for (const follower of followers) {
|
|
113
|
+
if (!follower.inbox) continue
|
|
114
|
+
try {
|
|
115
|
+
await outbox.send({
|
|
116
|
+
activity: create,
|
|
117
|
+
inbox: follower.inbox,
|
|
118
|
+
privateKey: config.privateKey,
|
|
119
|
+
keyId: `${profileUrl}#main-key`
|
|
120
|
+
})
|
|
121
|
+
results.success++
|
|
122
|
+
} catch (err) {
|
|
123
|
+
results.failed++
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { noteId, note, delivered: results }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Follow a remote user
|
|
132
|
+
*/
|
|
133
|
+
export async function follow(handle) {
|
|
134
|
+
loadConfig()
|
|
135
|
+
initStore()
|
|
136
|
+
|
|
137
|
+
// Parse handle (@user@domain or user@domain)
|
|
138
|
+
const cleanHandle = handle.replace(/^@/, '')
|
|
139
|
+
const [username, domain] = cleanHandle.split('@')
|
|
140
|
+
|
|
141
|
+
if (!username || !domain) {
|
|
142
|
+
throw new Error('Invalid handle. Use format: @user@domain or user@domain')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Resolve via WebFinger
|
|
146
|
+
console.log(`🔍 Looking up ${cleanHandle}...`)
|
|
147
|
+
const resolved = await webfinger.resolve(username, domain)
|
|
148
|
+
|
|
149
|
+
if (!resolved) {
|
|
150
|
+
throw new Error(`Could not find ${cleanHandle}`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Fetch the actor
|
|
154
|
+
console.log(`📥 Fetching actor...`)
|
|
155
|
+
const remoteActor = await fetchActor(resolved.actorId)
|
|
156
|
+
|
|
157
|
+
if (!remoteActor) {
|
|
158
|
+
throw new Error(`Could not fetch actor: ${resolved.actorId}`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const actorUrl = getActorUrl()
|
|
162
|
+
const profileUrl = getProfileUrl()
|
|
163
|
+
const inbox = remoteActor.inbox
|
|
164
|
+
|
|
165
|
+
// Create Follow activity
|
|
166
|
+
const followActivity = outbox.createFollow(actorUrl, remoteActor.id)
|
|
167
|
+
|
|
168
|
+
// Send Follow
|
|
169
|
+
console.log(`📤 Sending Follow to ${inbox}...`)
|
|
170
|
+
await outbox.send({
|
|
171
|
+
activity: followActivity,
|
|
172
|
+
inbox,
|
|
173
|
+
privateKey: config.privateKey,
|
|
174
|
+
keyId: `${profileUrl}#main-key`
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// Save to following (pending acceptance)
|
|
178
|
+
addFollowing(remoteActor.id, false)
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
actor: remoteActor,
|
|
182
|
+
followActivity
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Reply to a post
|
|
188
|
+
*/
|
|
189
|
+
export async function reply(postUrl, content) {
|
|
190
|
+
loadConfig()
|
|
191
|
+
initStore()
|
|
192
|
+
|
|
193
|
+
// Fetch the original post to get the author
|
|
194
|
+
let originalAuthor = null
|
|
195
|
+
try {
|
|
196
|
+
const response = await fetch(postUrl, {
|
|
197
|
+
headers: { 'Accept': 'application/activity+json' }
|
|
198
|
+
})
|
|
199
|
+
if (response.ok) {
|
|
200
|
+
const post = await response.json()
|
|
201
|
+
originalAuthor = post.attributedTo
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// Continue without original author
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const actorUrl = getActorUrl()
|
|
208
|
+
const profileUrl = getProfileUrl()
|
|
209
|
+
const noteId = `${profileUrl}/posts/${Date.now()}`
|
|
210
|
+
|
|
211
|
+
// Create the reply Note
|
|
212
|
+
const note = outbox.createNote(actorUrl, content, {
|
|
213
|
+
id: noteId,
|
|
214
|
+
inReplyTo: postUrl,
|
|
215
|
+
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
216
|
+
cc: [`${profileUrl}/followers`, originalAuthor].filter(Boolean)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// Wrap in Create activity
|
|
220
|
+
const create = {
|
|
221
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
222
|
+
type: 'Create',
|
|
223
|
+
id: `${noteId}/activity`,
|
|
224
|
+
actor: actorUrl,
|
|
225
|
+
published: new Date().toISOString(),
|
|
226
|
+
to: note.to,
|
|
227
|
+
cc: note.cc,
|
|
228
|
+
object: note
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Save locally
|
|
232
|
+
savePost(noteId, content, postUrl)
|
|
233
|
+
|
|
234
|
+
// Collect inboxes to deliver to
|
|
235
|
+
const inboxes = new Set()
|
|
236
|
+
|
|
237
|
+
// Add all followers
|
|
238
|
+
const followers = getFollowers()
|
|
239
|
+
for (const f of followers) {
|
|
240
|
+
if (f.inbox) inboxes.add(f.inbox)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Add original author's inbox
|
|
244
|
+
if (originalAuthor) {
|
|
245
|
+
const authorActor = await fetchActor(originalAuthor)
|
|
246
|
+
if (authorActor?.inbox) {
|
|
247
|
+
inboxes.add(authorActor.inbox)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Deliver
|
|
252
|
+
const results = { success: 0, failed: 0 }
|
|
253
|
+
for (const inbox of inboxes) {
|
|
254
|
+
try {
|
|
255
|
+
await outbox.send({
|
|
256
|
+
activity: create,
|
|
257
|
+
inbox,
|
|
258
|
+
privateKey: config.privateKey,
|
|
259
|
+
keyId: `${profileUrl}#main-key`
|
|
260
|
+
})
|
|
261
|
+
results.success++
|
|
262
|
+
} catch {
|
|
263
|
+
results.failed++
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { noteId, note, delivered: results }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Get timeline (posts from people we follow + mentions)
|
|
272
|
+
*/
|
|
273
|
+
export function timeline(limit = 20) {
|
|
274
|
+
loadConfig()
|
|
275
|
+
initStore()
|
|
276
|
+
|
|
277
|
+
// Get Create activities from inbox
|
|
278
|
+
const activities = getActivities(100)
|
|
279
|
+
|
|
280
|
+
const posts = activities
|
|
281
|
+
.filter(a => a.type === 'Create' && a.raw?.object)
|
|
282
|
+
.map(a => {
|
|
283
|
+
const obj = a.raw.object
|
|
284
|
+
return {
|
|
285
|
+
id: obj.id || a.id,
|
|
286
|
+
author: typeof a.raw.actor === 'string' ? a.raw.actor : a.raw.actor?.id,
|
|
287
|
+
content: obj.content || '',
|
|
288
|
+
published: obj.published || a.created_at,
|
|
289
|
+
inReplyTo: obj.inReplyTo
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
.slice(0, limit)
|
|
293
|
+
|
|
294
|
+
return posts
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Get our own posts
|
|
299
|
+
*/
|
|
300
|
+
export function myPosts(limit = 20) {
|
|
301
|
+
loadConfig()
|
|
302
|
+
initStore()
|
|
303
|
+
return getPosts(limit)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export default {
|
|
307
|
+
post,
|
|
308
|
+
follow,
|
|
309
|
+
reply,
|
|
310
|
+
timeline,
|
|
311
|
+
myPosts
|
|
312
|
+
}
|