fedbox 0.0.2 โ 0.0.5
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/bin/cli.js +39 -1
- package/lib/actions.js +27 -10
- package/lib/server.js +281 -103
- package/package.json +2 -2
- package/test/actions.test.js +78 -0
- package/test/store.test.js +233 -0
package/QUICKSTART.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Fedbox Quickstart
|
|
2
|
+
|
|
3
|
+
Clean slate setup with ngrok federation.
|
|
4
|
+
|
|
5
|
+
## 1. Clean existing data
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Remove database only
|
|
9
|
+
fedbox clean
|
|
10
|
+
|
|
11
|
+
# Remove everything (database + config + keys)
|
|
12
|
+
fedbox clean --all
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 2. Initialize
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
fedbox init
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Prompts for: username, display name, bio, port (default 3000).
|
|
22
|
+
|
|
23
|
+
Creates `fedbox.json` with generated keypair.
|
|
24
|
+
|
|
25
|
+
## 3. Start server
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
fedbox start
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Server runs at `http://localhost:3000/{username}`
|
|
32
|
+
|
|
33
|
+
## 4. Expose with ngrok
|
|
34
|
+
|
|
35
|
+
In another terminal:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
ngrok http 3000
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Copy the https URL (e.g., `https://abc123.ngrok-free.app`)
|
|
42
|
+
|
|
43
|
+
## 5. Configure domain
|
|
44
|
+
|
|
45
|
+
Edit `fedbox.json`, add domain (without https://):
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"domain": "abc123.ngrok-free.app",
|
|
50
|
+
...
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Restart server (`Ctrl+C`, then `fedbox start`).
|
|
55
|
+
|
|
56
|
+
## 6. Test federation
|
|
57
|
+
|
|
58
|
+
From Mastodon, search for `@{username}@{domain}`
|
|
59
|
+
|
|
60
|
+
## 7. Post something
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
fedbox post "Hello, Fediverse!"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## URI Structure (Solid-compatible)
|
|
67
|
+
|
|
68
|
+
| URI | Purpose |
|
|
69
|
+
|-----|---------|
|
|
70
|
+
| `/{username}` | Profile (HTML + JSON-LD) |
|
|
71
|
+
| `/{username}#me` | WebID (Actor ID) |
|
|
72
|
+
| `/{username}/inbox` | Inbox |
|
|
73
|
+
| `/{username}/outbox` | Outbox |
|
|
74
|
+
| `/{username}/posts/{id}` | Individual post |
|
|
75
|
+
| `/{username}#main-key` | Public key |
|
|
76
|
+
|
|
77
|
+
## All Commands
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
fedbox init # Setup identity
|
|
81
|
+
fedbox start # Start server
|
|
82
|
+
fedbox status # Show config
|
|
83
|
+
fedbox post "text" # Post to followers
|
|
84
|
+
fedbox follow @user@dom # Follow someone
|
|
85
|
+
fedbox timeline # View feed
|
|
86
|
+
fedbox reply <url> "text"# Reply to post
|
|
87
|
+
fedbox posts # View own posts
|
|
88
|
+
fedbox clean # Remove database
|
|
89
|
+
fedbox clean --all # Remove everything
|
|
90
|
+
```
|
package/bin/cli.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { createInterface } from 'readline'
|
|
9
|
-
import { existsSync, writeFileSync, mkdirSync, readFileSync } from 'fs'
|
|
9
|
+
import { existsSync, writeFileSync, mkdirSync, readFileSync, unlinkSync, rmSync } from 'fs'
|
|
10
10
|
import { generateKeypair } from 'microfed/auth'
|
|
11
11
|
|
|
12
12
|
const rl = createInterface({
|
|
@@ -34,6 +34,7 @@ const COMMANDS = {
|
|
|
34
34
|
timeline: runTimeline,
|
|
35
35
|
reply: runReply,
|
|
36
36
|
posts: runPosts,
|
|
37
|
+
clean: runClean,
|
|
37
38
|
help: runHelp
|
|
38
39
|
}
|
|
39
40
|
|
|
@@ -328,6 +329,42 @@ Create a post with: fedbox post "Hello, Fediverse!"
|
|
|
328
329
|
rl.close()
|
|
329
330
|
}
|
|
330
331
|
|
|
332
|
+
async function runClean() {
|
|
333
|
+
const all = process.argv[3] === '--all'
|
|
334
|
+
|
|
335
|
+
console.log('๐งน Cleaning up...\n')
|
|
336
|
+
|
|
337
|
+
// Remove database
|
|
338
|
+
if (existsSync('data/fedbox.db')) {
|
|
339
|
+
unlinkSync('data/fedbox.db')
|
|
340
|
+
console.log(' โ Removed data/fedbox.db')
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Remove data directory if empty
|
|
344
|
+
if (existsSync('data')) {
|
|
345
|
+
try {
|
|
346
|
+
rmSync('data', { recursive: false })
|
|
347
|
+
console.log(' โ Removed data/')
|
|
348
|
+
} catch {
|
|
349
|
+
// Directory not empty, that's ok
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Remove config if --all
|
|
354
|
+
if (all && existsSync('fedbox.json')) {
|
|
355
|
+
unlinkSync('fedbox.json')
|
|
356
|
+
console.log(' โ Removed fedbox.json')
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
console.log('\nโ
Clean complete!')
|
|
360
|
+
|
|
361
|
+
if (!all) {
|
|
362
|
+
console.log('\n Tip: Use "fedbox clean --all" to also remove config')
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
rl.close()
|
|
366
|
+
}
|
|
367
|
+
|
|
331
368
|
function runHelp() {
|
|
332
369
|
console.log(`
|
|
333
370
|
${BANNER}
|
|
@@ -346,6 +383,7 @@ Social:
|
|
|
346
383
|
posts View your own posts
|
|
347
384
|
|
|
348
385
|
Other:
|
|
386
|
+
clean Remove database (add --all to also remove config)
|
|
349
387
|
help Show this help
|
|
350
388
|
|
|
351
389
|
Quick start:
|
package/lib/actions.js
CHANGED
|
@@ -29,12 +29,26 @@ function loadConfig() {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Get
|
|
32
|
+
* Get base URL
|
|
33
33
|
*/
|
|
34
|
-
function
|
|
34
|
+
function getBaseUrl() {
|
|
35
35
|
const domain = config.domain || `localhost:${config.port}`
|
|
36
36
|
const protocol = config.domain ? 'https' : 'http'
|
|
37
|
-
return `${protocol}://${domain}
|
|
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`
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
/**
|
|
@@ -65,14 +79,15 @@ export async function post(content, inReplyTo = null) {
|
|
|
65
79
|
initStore()
|
|
66
80
|
|
|
67
81
|
const actorUrl = getActorUrl()
|
|
68
|
-
const
|
|
82
|
+
const profileUrl = getProfileUrl()
|
|
83
|
+
const noteId = `${profileUrl}/posts/${Date.now()}`
|
|
69
84
|
|
|
70
85
|
// Create the Note
|
|
71
86
|
const note = outbox.createNote(actorUrl, content, {
|
|
72
87
|
id: noteId,
|
|
73
88
|
inReplyTo,
|
|
74
89
|
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
75
|
-
cc: [`${
|
|
90
|
+
cc: [`${profileUrl}/followers`]
|
|
76
91
|
})
|
|
77
92
|
|
|
78
93
|
// Wrap in Create activity
|
|
@@ -101,7 +116,7 @@ export async function post(content, inReplyTo = null) {
|
|
|
101
116
|
activity: create,
|
|
102
117
|
inbox: follower.inbox,
|
|
103
118
|
privateKey: config.privateKey,
|
|
104
|
-
keyId: `${
|
|
119
|
+
keyId: `${profileUrl}#main-key`
|
|
105
120
|
})
|
|
106
121
|
results.success++
|
|
107
122
|
} catch (err) {
|
|
@@ -144,6 +159,7 @@ export async function follow(handle) {
|
|
|
144
159
|
}
|
|
145
160
|
|
|
146
161
|
const actorUrl = getActorUrl()
|
|
162
|
+
const profileUrl = getProfileUrl()
|
|
147
163
|
const inbox = remoteActor.inbox
|
|
148
164
|
|
|
149
165
|
// Create Follow activity
|
|
@@ -155,7 +171,7 @@ export async function follow(handle) {
|
|
|
155
171
|
activity: followActivity,
|
|
156
172
|
inbox,
|
|
157
173
|
privateKey: config.privateKey,
|
|
158
|
-
keyId: `${
|
|
174
|
+
keyId: `${profileUrl}#main-key`
|
|
159
175
|
})
|
|
160
176
|
|
|
161
177
|
// Save to following (pending acceptance)
|
|
@@ -189,14 +205,15 @@ export async function reply(postUrl, content) {
|
|
|
189
205
|
}
|
|
190
206
|
|
|
191
207
|
const actorUrl = getActorUrl()
|
|
192
|
-
const
|
|
208
|
+
const profileUrl = getProfileUrl()
|
|
209
|
+
const noteId = `${profileUrl}/posts/${Date.now()}`
|
|
193
210
|
|
|
194
211
|
// Create the reply Note
|
|
195
212
|
const note = outbox.createNote(actorUrl, content, {
|
|
196
213
|
id: noteId,
|
|
197
214
|
inReplyTo: postUrl,
|
|
198
215
|
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
199
|
-
cc: [`${
|
|
216
|
+
cc: [`${profileUrl}/followers`, originalAuthor].filter(Boolean)
|
|
200
217
|
})
|
|
201
218
|
|
|
202
219
|
// Wrap in Create activity
|
|
@@ -239,7 +256,7 @@ export async function reply(postUrl, content) {
|
|
|
239
256
|
activity: create,
|
|
240
257
|
inbox,
|
|
241
258
|
privateKey: config.privateKey,
|
|
242
|
-
keyId: `${
|
|
259
|
+
keyId: `${profileUrl}#main-key`
|
|
243
260
|
})
|
|
244
261
|
results.success++
|
|
245
262
|
} catch {
|
package/lib/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Fedbox Server
|
|
3
3
|
* ActivityPub server using microfed
|
|
4
|
+
* Solid-compatible URI structure
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import { createServer } from 'http'
|
|
@@ -58,21 +59,47 @@ function getProtocol() {
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/**
|
|
61
|
-
*
|
|
62
|
+
* Get base URL
|
|
62
63
|
*/
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const baseUrl = `${protocol}://${domain}`
|
|
64
|
+
function getBaseUrl() {
|
|
65
|
+
return `${getProtocol()}://${getDomain()}`
|
|
66
|
+
}
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Build actor object with Solid-compatible URIs
|
|
70
|
+
* /alice is the profile document
|
|
71
|
+
* /alice#me is the WebID (the Person)
|
|
72
|
+
*/
|
|
73
|
+
function buildActor() {
|
|
74
|
+
const baseUrl = getBaseUrl()
|
|
75
|
+
const profileUrl = `${baseUrl}/${config.username}`
|
|
76
|
+
const actorId = `${profileUrl}#me`
|
|
77
|
+
|
|
78
|
+
// Build actor manually for more control over structure
|
|
79
|
+
return {
|
|
80
|
+
'@context': [
|
|
81
|
+
'https://www.w3.org/ns/activitystreams',
|
|
82
|
+
'https://w3id.org/security/v1'
|
|
83
|
+
],
|
|
84
|
+
type: 'Person',
|
|
85
|
+
id: actorId,
|
|
86
|
+
url: profileUrl,
|
|
87
|
+
preferredUsername: config.username,
|
|
71
88
|
name: config.displayName,
|
|
72
89
|
summary: config.summary ? `<p>${config.summary}</p>` : '',
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
90
|
+
inbox: `${profileUrl}/inbox`,
|
|
91
|
+
outbox: `${profileUrl}/outbox`,
|
|
92
|
+
followers: `${profileUrl}/followers`,
|
|
93
|
+
following: `${profileUrl}/following`,
|
|
94
|
+
endpoints: {
|
|
95
|
+
sharedInbox: `${baseUrl}/inbox`
|
|
96
|
+
},
|
|
97
|
+
publicKey: {
|
|
98
|
+
id: `${profileUrl}#main-key`,
|
|
99
|
+
owner: actorId,
|
|
100
|
+
publicKeyPem: config.publicKey
|
|
101
|
+
}
|
|
102
|
+
}
|
|
76
103
|
}
|
|
77
104
|
|
|
78
105
|
/**
|
|
@@ -114,11 +141,13 @@ setInterval(cleanRateLimits, RATE_LIMIT_WINDOW)
|
|
|
114
141
|
* Fetch remote actor (with caching)
|
|
115
142
|
*/
|
|
116
143
|
async function fetchActor(id) {
|
|
144
|
+
// Strip fragment for fetching
|
|
145
|
+
const fetchUrl = id.replace(/#.*$/, '')
|
|
117
146
|
const cached = getCachedActor(id)
|
|
118
147
|
if (cached) return cached
|
|
119
148
|
|
|
120
149
|
try {
|
|
121
|
-
const response = await fetch(
|
|
150
|
+
const response = await fetch(fetchUrl, {
|
|
122
151
|
headers: { 'Accept': 'application/activity+json' }
|
|
123
152
|
})
|
|
124
153
|
if (!response.ok) return null
|
|
@@ -152,13 +181,13 @@ async function verifySignature(req, body) {
|
|
|
152
181
|
return { valid: false, reason: 'No keyId in signature' }
|
|
153
182
|
}
|
|
154
183
|
|
|
155
|
-
// Extract actor
|
|
156
|
-
const
|
|
184
|
+
// Extract actor URL from keyId (strip fragment like #main-key)
|
|
185
|
+
const actorUrl = keyId.replace(/#.*$/, '')
|
|
157
186
|
|
|
158
187
|
// Fetch the actor to get their public key
|
|
159
|
-
const remoteActor = await fetchActor(
|
|
188
|
+
const remoteActor = await fetchActor(actorUrl)
|
|
160
189
|
if (!remoteActor) {
|
|
161
|
-
return { valid: false, reason: `Could not fetch actor: ${
|
|
190
|
+
return { valid: false, reason: `Could not fetch actor: ${actorUrl}` }
|
|
162
191
|
}
|
|
163
192
|
|
|
164
193
|
const publicKeyPem = remoteActor.publicKey?.publicKeyPem
|
|
@@ -173,8 +202,7 @@ async function verifySignature(req, body) {
|
|
|
173
202
|
return `(request-target): ${req.method.toLowerCase()} ${req.url}`
|
|
174
203
|
}
|
|
175
204
|
if (header === 'digest' && body) {
|
|
176
|
-
|
|
177
|
-
const crypto = await import('crypto')
|
|
205
|
+
const crypto = require('crypto')
|
|
178
206
|
const digest = crypto.createHash('sha256').update(body).digest('base64')
|
|
179
207
|
return `digest: SHA-256=${digest}`
|
|
180
208
|
}
|
|
@@ -242,7 +270,7 @@ async function handleFollow(activity) {
|
|
|
242
270
|
activity: accept,
|
|
243
271
|
inbox: followerActor.inbox,
|
|
244
272
|
privateKey: config.privateKey,
|
|
245
|
-
keyId: `${
|
|
273
|
+
keyId: `${getBaseUrl()}/${config.username}#main-key`
|
|
246
274
|
})
|
|
247
275
|
console.log(` ๐ค Sent Accept to ${followerActor.inbox}`)
|
|
248
276
|
} catch (err) {
|
|
@@ -286,7 +314,7 @@ async function handleRequest(req, res) {
|
|
|
286
314
|
return res.end('Too many requests')
|
|
287
315
|
}
|
|
288
316
|
|
|
289
|
-
const url = new URL(req.url,
|
|
317
|
+
const url = new URL(req.url, getBaseUrl())
|
|
290
318
|
const path = url.pathname
|
|
291
319
|
const accept = req.headers.accept || ''
|
|
292
320
|
const isAP = accept.includes('activity+json') || accept.includes('ld+json')
|
|
@@ -313,102 +341,111 @@ async function handleRequest(req, res) {
|
|
|
313
341
|
return res.end('Not found')
|
|
314
342
|
}
|
|
315
343
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
344
|
+
// Return /alice#me as the actor (Solid-compatible WebID)
|
|
345
|
+
const profileUrl = `${getBaseUrl()}/${config.username}`
|
|
346
|
+
const response = {
|
|
347
|
+
subject: `acct:${config.username}@${getDomain()}`,
|
|
348
|
+
links: [
|
|
349
|
+
{
|
|
350
|
+
rel: 'self',
|
|
351
|
+
type: 'application/activity+json',
|
|
352
|
+
href: `${profileUrl}#me`
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
rel: 'http://webfinger.net/rel/profile-page',
|
|
356
|
+
type: 'text/html',
|
|
357
|
+
href: profileUrl
|
|
358
|
+
}
|
|
359
|
+
]
|
|
360
|
+
}
|
|
321
361
|
|
|
322
362
|
res.setHeader('Content-Type', 'application/jrd+json')
|
|
323
363
|
return res.end(JSON.stringify(response, null, 2))
|
|
324
364
|
}
|
|
325
365
|
|
|
326
|
-
//
|
|
327
|
-
if (path ===
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
366
|
+
// Nodeinfo discovery
|
|
367
|
+
if (path === '/.well-known/nodeinfo') {
|
|
368
|
+
const response = {
|
|
369
|
+
links: [
|
|
370
|
+
{
|
|
371
|
+
rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
|
|
372
|
+
href: `${getBaseUrl()}/nodeinfo/2.1`
|
|
373
|
+
}
|
|
374
|
+
]
|
|
354
375
|
}
|
|
355
|
-
|
|
356
|
-
res.
|
|
357
|
-
return res.end(JSON.stringify(note, null, 2))
|
|
376
|
+
res.setHeader('Content-Type', 'application/json')
|
|
377
|
+
return res.end(JSON.stringify(response, null, 2))
|
|
358
378
|
}
|
|
359
379
|
|
|
360
|
-
//
|
|
361
|
-
if (path ===
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
380
|
+
// Nodeinfo 2.1
|
|
381
|
+
if (path === '/nodeinfo/2.1') {
|
|
382
|
+
const response = {
|
|
383
|
+
version: '2.1',
|
|
384
|
+
software: {
|
|
385
|
+
name: 'fedbox',
|
|
386
|
+
version: '0.0.5',
|
|
387
|
+
repository: 'https://github.com/micro-fed/fedbox'
|
|
388
|
+
},
|
|
389
|
+
protocols: ['activitypub'],
|
|
390
|
+
services: { inbound: [], outbound: [] },
|
|
391
|
+
usage: {
|
|
392
|
+
users: { total: 1, activeMonth: 1, activeHalfyear: 1 },
|
|
393
|
+
localPosts: getPosts(1000).length
|
|
394
|
+
},
|
|
395
|
+
openRegistrations: false,
|
|
396
|
+
metadata: {
|
|
397
|
+
nodeName: config.displayName || config.username,
|
|
398
|
+
nodeDescription: config.summary || 'A Fedbox instance'
|
|
399
|
+
}
|
|
365
400
|
}
|
|
401
|
+
res.setHeader('Content-Type', 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"')
|
|
402
|
+
return res.end(JSON.stringify(response, null, 2))
|
|
403
|
+
}
|
|
366
404
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
405
|
+
// Shared inbox
|
|
406
|
+
if (path === '/inbox') {
|
|
407
|
+
return handleInbox(req, res)
|
|
408
|
+
}
|
|
370
409
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
if (
|
|
374
|
-
|
|
375
|
-
|
|
410
|
+
// Profile routes: /alice, /alice/inbox, /alice/outbox, etc.
|
|
411
|
+
if (path === `/${config.username}`) {
|
|
412
|
+
if (isAP) {
|
|
413
|
+
// Return Actor JSON-LD
|
|
414
|
+
res.setHeader('Content-Type', 'application/activity+json')
|
|
415
|
+
return res.end(JSON.stringify(actor, null, 2))
|
|
376
416
|
} else {
|
|
377
|
-
|
|
417
|
+
// Return HTML with embedded JSON-LD
|
|
418
|
+
res.setHeader('Content-Type', 'text/html')
|
|
419
|
+
return res.end(renderProfile())
|
|
378
420
|
}
|
|
421
|
+
}
|
|
379
422
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
res.writeHead(202)
|
|
384
|
-
return res.end()
|
|
385
|
-
} catch (err) {
|
|
386
|
-
console.error('Inbox error:', err)
|
|
387
|
-
res.writeHead(400)
|
|
388
|
-
return res.end('Bad request')
|
|
389
|
-
}
|
|
423
|
+
// Inbox
|
|
424
|
+
if (path === `/${config.username}/inbox`) {
|
|
425
|
+
return handleInbox(req, res)
|
|
390
426
|
}
|
|
391
427
|
|
|
392
428
|
// Outbox
|
|
393
|
-
if (path ===
|
|
429
|
+
if (path === `/${config.username}/outbox`) {
|
|
394
430
|
const posts = getPosts(20)
|
|
431
|
+
const profileUrl = `${getBaseUrl()}/${config.username}`
|
|
395
432
|
const collection = {
|
|
396
433
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
397
434
|
type: 'OrderedCollection',
|
|
398
|
-
id: `${
|
|
435
|
+
id: `${profileUrl}/outbox`,
|
|
399
436
|
totalItems: posts.length,
|
|
400
437
|
orderedItems: posts.map(p => ({
|
|
401
438
|
type: 'Create',
|
|
402
|
-
actor:
|
|
439
|
+
actor: `${profileUrl}#me`,
|
|
403
440
|
published: p.published,
|
|
404
441
|
object: {
|
|
405
442
|
type: 'Note',
|
|
406
443
|
id: p.id,
|
|
407
444
|
content: p.content,
|
|
408
445
|
published: p.published,
|
|
409
|
-
attributedTo:
|
|
446
|
+
attributedTo: `${profileUrl}#me`,
|
|
410
447
|
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
411
|
-
cc: [`${
|
|
448
|
+
cc: [`${profileUrl}/followers`],
|
|
412
449
|
...(p.in_reply_to ? { inReplyTo: p.in_reply_to } : {})
|
|
413
450
|
}
|
|
414
451
|
}))
|
|
@@ -418,12 +455,13 @@ async function handleRequest(req, res) {
|
|
|
418
455
|
}
|
|
419
456
|
|
|
420
457
|
// Followers
|
|
421
|
-
if (path ===
|
|
458
|
+
if (path === `/${config.username}/followers`) {
|
|
422
459
|
const followers = getFollowers()
|
|
460
|
+
const profileUrl = `${getBaseUrl()}/${config.username}`
|
|
423
461
|
const collection = {
|
|
424
462
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
425
463
|
type: 'OrderedCollection',
|
|
426
|
-
id: `${
|
|
464
|
+
id: `${profileUrl}/followers`,
|
|
427
465
|
totalItems: followers.length,
|
|
428
466
|
orderedItems: followers.map(f => f.actor)
|
|
429
467
|
}
|
|
@@ -432,11 +470,12 @@ async function handleRequest(req, res) {
|
|
|
432
470
|
}
|
|
433
471
|
|
|
434
472
|
// Following
|
|
435
|
-
if (path ===
|
|
473
|
+
if (path === `/${config.username}/following`) {
|
|
474
|
+
const profileUrl = `${getBaseUrl()}/${config.username}`
|
|
436
475
|
const collection = {
|
|
437
476
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
438
477
|
type: 'OrderedCollection',
|
|
439
|
-
id: `${
|
|
478
|
+
id: `${profileUrl}/following`,
|
|
440
479
|
totalItems: getFollowingCount(),
|
|
441
480
|
orderedItems: []
|
|
442
481
|
}
|
|
@@ -444,10 +483,38 @@ async function handleRequest(req, res) {
|
|
|
444
483
|
return res.end(JSON.stringify(collection, null, 2))
|
|
445
484
|
}
|
|
446
485
|
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
486
|
+
// Individual post: /alice/posts/123
|
|
487
|
+
const postMatch = path.match(new RegExp(`^/${config.username}/posts/(\\d+)$`))
|
|
488
|
+
if (postMatch) {
|
|
489
|
+
const postId = `${getBaseUrl()}${path}`
|
|
490
|
+
const post = getPost(postId)
|
|
491
|
+
if (!post) {
|
|
492
|
+
res.writeHead(404)
|
|
493
|
+
return res.end('Not found')
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const profileUrl = `${getBaseUrl()}/${config.username}`
|
|
497
|
+
const note = {
|
|
498
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
499
|
+
type: 'Note',
|
|
500
|
+
id: post.id,
|
|
501
|
+
attributedTo: `${profileUrl}#me`,
|
|
502
|
+
content: post.content,
|
|
503
|
+
published: post.published,
|
|
504
|
+
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
505
|
+
cc: [`${profileUrl}/followers`]
|
|
506
|
+
}
|
|
507
|
+
if (post.in_reply_to) {
|
|
508
|
+
note.inReplyTo = post.in_reply_to
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (isAP) {
|
|
512
|
+
res.setHeader('Content-Type', 'application/activity+json')
|
|
513
|
+
return res.end(JSON.stringify(note, null, 2))
|
|
514
|
+
} else {
|
|
515
|
+
res.setHeader('Content-Type', 'text/html')
|
|
516
|
+
return res.end(renderPost(post, note))
|
|
517
|
+
}
|
|
451
518
|
}
|
|
452
519
|
|
|
453
520
|
// Home
|
|
@@ -461,12 +528,46 @@ async function handleRequest(req, res) {
|
|
|
461
528
|
}
|
|
462
529
|
|
|
463
530
|
/**
|
|
464
|
-
*
|
|
531
|
+
* Handle inbox POST
|
|
532
|
+
*/
|
|
533
|
+
async function handleInbox(req, res) {
|
|
534
|
+
if (req.method !== 'POST') {
|
|
535
|
+
res.writeHead(405)
|
|
536
|
+
return res.end('Method not allowed')
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const chunks = []
|
|
540
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
541
|
+
const body = Buffer.concat(chunks).toString()
|
|
542
|
+
|
|
543
|
+
// Verify signature
|
|
544
|
+
const sigResult = await verifySignature(req, body)
|
|
545
|
+
if (!sigResult.valid) {
|
|
546
|
+
console.log(` โ ๏ธ Signature: ${sigResult.reason}`)
|
|
547
|
+
} else {
|
|
548
|
+
console.log(` ๐ Signature verified`)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
const activity = JSON.parse(body)
|
|
553
|
+
await handleActivity(activity)
|
|
554
|
+
res.writeHead(202)
|
|
555
|
+
return res.end()
|
|
556
|
+
} catch (err) {
|
|
557
|
+
console.error('Inbox error:', err)
|
|
558
|
+
res.writeHead(400)
|
|
559
|
+
return res.end('Bad request')
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Render HTML profile with embedded JSON-LD
|
|
465
565
|
*/
|
|
466
566
|
function renderProfile() {
|
|
467
567
|
const followers = getFollowerCount()
|
|
468
568
|
const following = getFollowingCount()
|
|
469
569
|
const posts = getPosts(10)
|
|
570
|
+
const profileUrl = `${getBaseUrl()}/${config.username}`
|
|
470
571
|
|
|
471
572
|
return `<!DOCTYPE html>
|
|
472
573
|
<html>
|
|
@@ -474,6 +575,10 @@ function renderProfile() {
|
|
|
474
575
|
<meta charset="utf-8">
|
|
475
576
|
<title>${config.displayName} (@${config.username}@${getDomain()})</title>
|
|
476
577
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
578
|
+
<link rel="alternate" type="application/activity+json" href="${profileUrl}">
|
|
579
|
+
<script type="application/ld+json">
|
|
580
|
+
${JSON.stringify(actor, null, 2)}
|
|
581
|
+
</script>
|
|
477
582
|
<style>
|
|
478
583
|
* { box-sizing: border-box; }
|
|
479
584
|
body {
|
|
@@ -528,6 +633,7 @@ function renderProfile() {
|
|
|
528
633
|
}
|
|
529
634
|
.post-content { margin-bottom: 0.5rem; }
|
|
530
635
|
.post-meta { color: #666; font-size: 0.8rem; }
|
|
636
|
+
.post a { color: #667eea; text-decoration: none; }
|
|
531
637
|
</style>
|
|
532
638
|
</head>
|
|
533
639
|
<body>
|
|
@@ -558,7 +664,9 @@ function renderProfile() {
|
|
|
558
664
|
${posts.map(p => `
|
|
559
665
|
<div class="post">
|
|
560
666
|
<div class="post-content">${p.content}</div>
|
|
561
|
-
<div class="post-meta"
|
|
667
|
+
<div class="post-meta">
|
|
668
|
+
<a href="${p.id}">${new Date(p.published).toLocaleString()}</a>
|
|
669
|
+
</div>
|
|
562
670
|
</div>
|
|
563
671
|
`).join('')}
|
|
564
672
|
</div>
|
|
@@ -567,10 +675,79 @@ function renderProfile() {
|
|
|
567
675
|
</html>`
|
|
568
676
|
}
|
|
569
677
|
|
|
678
|
+
/**
|
|
679
|
+
* Render individual post with embedded JSON-LD
|
|
680
|
+
*/
|
|
681
|
+
function renderPost(post, note) {
|
|
682
|
+
return `<!DOCTYPE html>
|
|
683
|
+
<html>
|
|
684
|
+
<head>
|
|
685
|
+
<meta charset="utf-8">
|
|
686
|
+
<title>Post by ${config.displayName}</title>
|
|
687
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
688
|
+
<link rel="alternate" type="application/activity+json" href="${post.id}">
|
|
689
|
+
<script type="application/ld+json">
|
|
690
|
+
${JSON.stringify(note, null, 2)}
|
|
691
|
+
</script>
|
|
692
|
+
<style>
|
|
693
|
+
* { box-sizing: border-box; }
|
|
694
|
+
body {
|
|
695
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
696
|
+
max-width: 600px;
|
|
697
|
+
margin: 0 auto;
|
|
698
|
+
padding: 2rem;
|
|
699
|
+
background: #1a1a2e;
|
|
700
|
+
color: #eee;
|
|
701
|
+
min-height: 100vh;
|
|
702
|
+
}
|
|
703
|
+
.post {
|
|
704
|
+
background: #16213e;
|
|
705
|
+
border-radius: 16px;
|
|
706
|
+
padding: 2rem;
|
|
707
|
+
}
|
|
708
|
+
.author {
|
|
709
|
+
display: flex;
|
|
710
|
+
align-items: center;
|
|
711
|
+
gap: 1rem;
|
|
712
|
+
margin-bottom: 1rem;
|
|
713
|
+
}
|
|
714
|
+
.avatar {
|
|
715
|
+
width: 48px;
|
|
716
|
+
height: 48px;
|
|
717
|
+
border-radius: 50%;
|
|
718
|
+
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
719
|
+
display: flex;
|
|
720
|
+
align-items: center;
|
|
721
|
+
justify-content: center;
|
|
722
|
+
font-size: 1.5rem;
|
|
723
|
+
}
|
|
724
|
+
.author-info a { color: #667eea; text-decoration: none; }
|
|
725
|
+
.author-info p { margin: 0; color: #888; font-size: 0.9rem; }
|
|
726
|
+
.content { font-size: 1.2rem; line-height: 1.6; margin: 1rem 0; }
|
|
727
|
+
.meta { color: #666; font-size: 0.9rem; }
|
|
728
|
+
</style>
|
|
729
|
+
</head>
|
|
730
|
+
<body>
|
|
731
|
+
<div class="post">
|
|
732
|
+
<div class="author">
|
|
733
|
+
<div class="avatar">๐ฆ</div>
|
|
734
|
+
<div class="author-info">
|
|
735
|
+
<a href="/${config.username}">${config.displayName}</a>
|
|
736
|
+
<p>@${config.username}@${getDomain()}</p>
|
|
737
|
+
</div>
|
|
738
|
+
</div>
|
|
739
|
+
<div class="content">${post.content}</div>
|
|
740
|
+
<div class="meta">${new Date(post.published).toLocaleString()}</div>
|
|
741
|
+
</div>
|
|
742
|
+
</body>
|
|
743
|
+
</html>`
|
|
744
|
+
}
|
|
745
|
+
|
|
570
746
|
/**
|
|
571
747
|
* Render home page
|
|
572
748
|
*/
|
|
573
749
|
function renderHome() {
|
|
750
|
+
const profileUrl = `${getBaseUrl()}/${config.username}`
|
|
574
751
|
return `<!DOCTYPE html>
|
|
575
752
|
<html>
|
|
576
753
|
<head>
|
|
@@ -598,15 +775,16 @@ function renderHome() {
|
|
|
598
775
|
<p>Your Fediverse server is running!</p>
|
|
599
776
|
|
|
600
777
|
<h2>Your Profile</h2>
|
|
601
|
-
<p><a href="
|
|
778
|
+
<p><a href="/${config.username}">@${config.username}@${getDomain()}</a></p>
|
|
602
779
|
|
|
603
780
|
<h2>Endpoints</h2>
|
|
604
781
|
<ul class="endpoints">
|
|
605
782
|
<li><code>/.well-known/webfinger</code> - Discovery</li>
|
|
606
|
-
<li><code
|
|
607
|
-
<li><code
|
|
608
|
-
<li><code
|
|
609
|
-
<li><code
|
|
783
|
+
<li><code>/${config.username}</code> - Profile (HTML + JSON-LD)</li>
|
|
784
|
+
<li><code>/${config.username}#me</code> - WebID (Actor)</li>
|
|
785
|
+
<li><code>/${config.username}/inbox</code> - Inbox</li>
|
|
786
|
+
<li><code>/${config.username}/outbox</code> - Outbox</li>
|
|
787
|
+
<li><code>/${config.username}/followers</code> - Followers</li>
|
|
610
788
|
</ul>
|
|
611
789
|
|
|
612
790
|
<h2>Federation</h2>
|
|
@@ -631,10 +809,10 @@ export async function startServer() {
|
|
|
631
809
|
console.log(`
|
|
632
810
|
๐ฆ Fedbox is running!
|
|
633
811
|
|
|
634
|
-
Profile: http://localhost:${config.port}
|
|
635
|
-
|
|
812
|
+
Profile: http://localhost:${config.port}/${config.username}
|
|
813
|
+
WebID: http://localhost:${config.port}/${config.username}#me
|
|
636
814
|
|
|
637
|
-
${config.domain ? ` Federated: https://${config.domain}
|
|
815
|
+
${config.domain ? ` Federated: https://${config.domain}/${config.username}` : ' โ ๏ธ Set "domain" in fedbox.json for federation'}
|
|
638
816
|
|
|
639
817
|
Press Ctrl+C to stop
|
|
640
818
|
`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedbox",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "Zero to Fediverse in 60 seconds",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/server.js",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node lib/server.js",
|
|
12
|
-
"test": "node --test test
|
|
12
|
+
"test": "node --test 'test/*.test.js'"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"microfed": "^0.0.13",
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actions tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { test, describe, before, after } from 'node:test'
|
|
6
|
+
import assert from 'node:assert'
|
|
7
|
+
import { writeFileSync, unlinkSync, existsSync, mkdirSync, rmSync } from 'fs'
|
|
8
|
+
import { generateKeypair } from 'microfed/auth'
|
|
9
|
+
|
|
10
|
+
function setupConfig(domain = null) {
|
|
11
|
+
const { publicKey, privateKey } = generateKeypair()
|
|
12
|
+
const config = {
|
|
13
|
+
username: 'alice',
|
|
14
|
+
displayName: 'Alice',
|
|
15
|
+
summary: 'Test user',
|
|
16
|
+
port: 3000,
|
|
17
|
+
publicKey,
|
|
18
|
+
privateKey
|
|
19
|
+
}
|
|
20
|
+
if (domain) config.domain = domain
|
|
21
|
+
writeFileSync('fedbox.json', JSON.stringify(config, null, 2))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function cleanup() {
|
|
25
|
+
if (existsSync('fedbox.json')) unlinkSync('fedbox.json')
|
|
26
|
+
if (existsSync('data/fedbox.db')) unlinkSync('data/fedbox.db')
|
|
27
|
+
try { rmSync('data', { recursive: true }) } catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('Actions', () => {
|
|
31
|
+
before(() => {
|
|
32
|
+
cleanup()
|
|
33
|
+
if (!existsSync('data')) mkdirSync('data')
|
|
34
|
+
setupConfig()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
after(() => {
|
|
38
|
+
cleanup()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('post creates note with Solid-compatible noteId', async () => {
|
|
42
|
+
const { post } = await import('../lib/actions.js')
|
|
43
|
+
const result = await post('Hello world!')
|
|
44
|
+
|
|
45
|
+
// noteId should be /alice/posts/xxx (not /alice#me/posts/xxx)
|
|
46
|
+
assert.ok(result.noteId.includes('/alice/posts/'), 'noteId should include /alice/posts/')
|
|
47
|
+
assert.ok(!result.noteId.includes('#me/posts'), 'noteId should not have #me before /posts')
|
|
48
|
+
assert.ok(!result.noteId.includes('#me'), 'noteId should not contain #me at all')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('post returns delivery results', async () => {
|
|
52
|
+
const { post } = await import('../lib/actions.js')
|
|
53
|
+
const result = await post('Test delivery')
|
|
54
|
+
|
|
55
|
+
assert.ok('delivered' in result, 'result should have delivered stats')
|
|
56
|
+
assert.ok('success' in result.delivered)
|
|
57
|
+
assert.ok('failed' in result.delivered)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('myPosts returns saved posts', async () => {
|
|
61
|
+
const { myPosts } = await import('../lib/actions.js')
|
|
62
|
+
const posts = myPosts(10)
|
|
63
|
+
// Should have posts from previous tests
|
|
64
|
+
assert.ok(posts.length >= 1, 'should have at least 1 post')
|
|
65
|
+
assert.ok(posts[0].content, 'post should have content')
|
|
66
|
+
assert.ok(posts[0].id, 'post should have id')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('myPosts posts have Solid-compatible URLs', async () => {
|
|
70
|
+
const { myPosts } = await import('../lib/actions.js')
|
|
71
|
+
const posts = myPosts(10)
|
|
72
|
+
|
|
73
|
+
for (const post of posts) {
|
|
74
|
+
assert.ok(post.id.includes('/alice/posts/'), 'post id should include /alice/posts/')
|
|
75
|
+
assert.ok(!post.id.includes('#me'), 'post id should not contain #me')
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { test, describe, beforeEach, afterEach } from 'node:test'
|
|
6
|
+
import assert from 'node:assert'
|
|
7
|
+
import { unlinkSync, existsSync, mkdirSync } from 'fs'
|
|
8
|
+
import {
|
|
9
|
+
initStore,
|
|
10
|
+
getStore,
|
|
11
|
+
addFollower,
|
|
12
|
+
removeFollower,
|
|
13
|
+
getFollowers,
|
|
14
|
+
getFollowerCount,
|
|
15
|
+
addFollowing,
|
|
16
|
+
acceptFollowing,
|
|
17
|
+
getFollowing,
|
|
18
|
+
getFollowingCount,
|
|
19
|
+
savePost,
|
|
20
|
+
getPosts,
|
|
21
|
+
getPost,
|
|
22
|
+
cacheActor,
|
|
23
|
+
getCachedActor,
|
|
24
|
+
saveActivity,
|
|
25
|
+
getActivities
|
|
26
|
+
} from '../lib/store.js'
|
|
27
|
+
|
|
28
|
+
const TEST_DB = 'test/test.db'
|
|
29
|
+
|
|
30
|
+
function cleanup() {
|
|
31
|
+
if (existsSync(TEST_DB)) {
|
|
32
|
+
unlinkSync(TEST_DB)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('Store', () => {
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
cleanup()
|
|
39
|
+
if (!existsSync('test')) mkdirSync('test', { recursive: true })
|
|
40
|
+
initStore(TEST_DB)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
const db = getStore()
|
|
45
|
+
db.close()
|
|
46
|
+
cleanup()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('initStore creates database', () => {
|
|
50
|
+
assert.ok(existsSync(TEST_DB))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('getStore returns database instance', () => {
|
|
54
|
+
const db = getStore()
|
|
55
|
+
assert.ok(db)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('Followers', () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
cleanup()
|
|
62
|
+
initStore(TEST_DB)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
const db = getStore()
|
|
67
|
+
db.close()
|
|
68
|
+
cleanup()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('addFollower and getFollowers', () => {
|
|
72
|
+
addFollower('https://example.com/user/alice', 'https://example.com/user/alice/inbox')
|
|
73
|
+
const followers = getFollowers()
|
|
74
|
+
assert.strictEqual(followers.length, 1)
|
|
75
|
+
assert.strictEqual(followers[0].actor, 'https://example.com/user/alice')
|
|
76
|
+
assert.strictEqual(followers[0].inbox, 'https://example.com/user/alice/inbox')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('getFollowerCount', () => {
|
|
80
|
+
assert.strictEqual(getFollowerCount(), 0)
|
|
81
|
+
addFollower('https://example.com/user/alice', 'https://example.com/inbox')
|
|
82
|
+
assert.strictEqual(getFollowerCount(), 1)
|
|
83
|
+
addFollower('https://example.com/user/bob', 'https://example.com/inbox')
|
|
84
|
+
assert.strictEqual(getFollowerCount(), 2)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('removeFollower', () => {
|
|
88
|
+
addFollower('https://example.com/user/alice', 'https://example.com/inbox')
|
|
89
|
+
assert.strictEqual(getFollowerCount(), 1)
|
|
90
|
+
removeFollower('https://example.com/user/alice')
|
|
91
|
+
assert.strictEqual(getFollowerCount(), 0)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('Following', () => {
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
cleanup()
|
|
98
|
+
initStore(TEST_DB)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
const db = getStore()
|
|
103
|
+
db.close()
|
|
104
|
+
cleanup()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('addFollowing with pending status', () => {
|
|
108
|
+
addFollowing('https://example.com/user/bob', false)
|
|
109
|
+
assert.strictEqual(getFollowingCount(), 0) // Not accepted yet
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('acceptFollowing', () => {
|
|
113
|
+
addFollowing('https://example.com/user/bob', false)
|
|
114
|
+
assert.strictEqual(getFollowingCount(), 0)
|
|
115
|
+
acceptFollowing('https://example.com/user/bob')
|
|
116
|
+
assert.strictEqual(getFollowingCount(), 1)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('getFollowing returns only accepted', () => {
|
|
120
|
+
addFollowing('https://example.com/user/bob', false)
|
|
121
|
+
addFollowing('https://example.com/user/charlie', true)
|
|
122
|
+
const following = getFollowing()
|
|
123
|
+
assert.strictEqual(following.length, 1)
|
|
124
|
+
assert.strictEqual(following[0].actor, 'https://example.com/user/charlie')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('Posts', () => {
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
cleanup()
|
|
131
|
+
initStore(TEST_DB)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
afterEach(() => {
|
|
135
|
+
const db = getStore()
|
|
136
|
+
db.close()
|
|
137
|
+
cleanup()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('savePost and getPost', () => {
|
|
141
|
+
const id = 'https://example.com/alice/posts/123'
|
|
142
|
+
savePost(id, 'Hello world!')
|
|
143
|
+
const post = getPost(id)
|
|
144
|
+
assert.ok(post)
|
|
145
|
+
assert.strictEqual(post.id, id)
|
|
146
|
+
assert.strictEqual(post.content, 'Hello world!')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('savePost with inReplyTo', () => {
|
|
150
|
+
const id = 'https://example.com/alice/posts/456'
|
|
151
|
+
const replyTo = 'https://other.com/posts/789'
|
|
152
|
+
savePost(id, 'This is a reply', replyTo)
|
|
153
|
+
const post = getPost(id)
|
|
154
|
+
assert.strictEqual(post.in_reply_to, replyTo)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('getPosts returns posts', () => {
|
|
158
|
+
savePost('https://example.com/posts/1', 'First')
|
|
159
|
+
savePost('https://example.com/posts/2', 'Second')
|
|
160
|
+
savePost('https://example.com/posts/3', 'Third')
|
|
161
|
+
const posts = getPosts(10)
|
|
162
|
+
assert.strictEqual(posts.length, 3)
|
|
163
|
+
// All posts should be present
|
|
164
|
+
const contents = posts.map(p => p.content)
|
|
165
|
+
assert.ok(contents.includes('First'))
|
|
166
|
+
assert.ok(contents.includes('Second'))
|
|
167
|
+
assert.ok(contents.includes('Third'))
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('getPosts respects limit', () => {
|
|
171
|
+
for (let i = 0; i < 10; i++) {
|
|
172
|
+
savePost(`https://example.com/posts/${i}`, `Post ${i}`)
|
|
173
|
+
}
|
|
174
|
+
const posts = getPosts(5)
|
|
175
|
+
assert.strictEqual(posts.length, 5)
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('Actor Cache', () => {
|
|
180
|
+
beforeEach(() => {
|
|
181
|
+
cleanup()
|
|
182
|
+
initStore(TEST_DB)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
afterEach(() => {
|
|
186
|
+
const db = getStore()
|
|
187
|
+
db.close()
|
|
188
|
+
cleanup()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('cacheActor and getCachedActor', () => {
|
|
192
|
+
const actor = {
|
|
193
|
+
id: 'https://example.com/user/alice',
|
|
194
|
+
type: 'Person',
|
|
195
|
+
preferredUsername: 'alice'
|
|
196
|
+
}
|
|
197
|
+
cacheActor(actor)
|
|
198
|
+
const cached = getCachedActor(actor.id)
|
|
199
|
+
assert.deepStrictEqual(cached, actor)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('getCachedActor returns null for unknown', () => {
|
|
203
|
+
const cached = getCachedActor('https://example.com/unknown')
|
|
204
|
+
assert.strictEqual(cached, null)
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
describe('Activities', () => {
|
|
209
|
+
beforeEach(() => {
|
|
210
|
+
cleanup()
|
|
211
|
+
initStore(TEST_DB)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
afterEach(() => {
|
|
215
|
+
const db = getStore()
|
|
216
|
+
db.close()
|
|
217
|
+
cleanup()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('saveActivity and getActivities', () => {
|
|
221
|
+
const activity = {
|
|
222
|
+
id: 'https://example.com/activity/1',
|
|
223
|
+
type: 'Create',
|
|
224
|
+
actor: 'https://example.com/user/alice',
|
|
225
|
+
object: { type: 'Note', content: 'Hello' }
|
|
226
|
+
}
|
|
227
|
+
saveActivity(activity)
|
|
228
|
+
const activities = getActivities(10)
|
|
229
|
+
assert.strictEqual(activities.length, 1)
|
|
230
|
+
assert.strictEqual(activities[0].type, 'Create')
|
|
231
|
+
assert.deepStrictEqual(activities[0].raw, activity)
|
|
232
|
+
})
|
|
233
|
+
})
|