fedbox 0.0.4 → 0.0.6

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/lib/server.js CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { createServer } from 'http'
8
8
  import { readFileSync, existsSync } from 'fs'
9
+ import { join, extname } from 'path'
9
10
  import { profile, auth, webfinger, outbox } from 'microfed'
10
11
  import {
11
12
  initStore,
@@ -383,7 +384,7 @@ async function handleRequest(req, res) {
383
384
  version: '2.1',
384
385
  software: {
385
386
  name: 'fedbox',
386
- version: '0.0.4',
387
+ version: '0.0.6',
387
388
  repository: 'https://github.com/micro-fed/fedbox'
388
389
  },
389
390
  protocols: ['activitypub'],
@@ -523,6 +524,25 @@ async function handleRequest(req, res) {
523
524
  return res.end(renderHome())
524
525
  }
525
526
 
527
+ // Static files from /public/
528
+ if (path.startsWith('/public/')) {
529
+ const filePath = join(process.cwd(), path)
530
+ if (existsSync(filePath)) {
531
+ const ext = extname(filePath)
532
+ const mimeTypes = {
533
+ '.js': 'application/javascript',
534
+ '.css': 'text/css',
535
+ '.html': 'text/html',
536
+ '.json': 'application/json',
537
+ '.png': 'image/png',
538
+ '.jpg': 'image/jpeg',
539
+ '.svg': 'image/svg+xml'
540
+ }
541
+ res.setHeader('Content-Type', mimeTypes[ext] || 'text/plain')
542
+ return res.end(readFileSync(filePath))
543
+ }
544
+ }
545
+
526
546
  res.writeHead(404)
527
547
  res.end('Not found')
528
548
  }
@@ -576,7 +596,7 @@ function renderProfile() {
576
596
  <title>${config.displayName} (@${config.username}@${getDomain()})</title>
577
597
  <meta name="viewport" content="width=device-width, initial-scale=1">
578
598
  <link rel="alternate" type="application/activity+json" href="${profileUrl}">
579
- <script type="application/ld+json">
599
+ <script type="application/ld+json" id="profile">
580
600
  ${JSON.stringify(actor, null, 2)}
581
601
  </script>
582
602
  <style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedbox",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Zero to Fediverse in 60 seconds",
5
5
  "type": "module",
6
6
  "main": "lib/server.js",
@@ -9,11 +9,11 @@
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
- "microfed": "^0.0.13",
16
- "better-sqlite3": "^11.0.0"
15
+ "better-sqlite3": "^11.0.0",
16
+ "microfed": "^0.0.14"
17
17
  },
18
18
  "keywords": [
19
19
  "activitypub",
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Microfed Profile Component
3
+ * Reads JSON-LD data island and renders a profile card
4
+ *
5
+ * Usage: <script src="profile.js"></script>
6
+ * Requires: <script type="application/ld+json" id="profile">...</script>
7
+ */
8
+ (function() {
9
+ 'use strict'
10
+
11
+ function init() {
12
+ // Find JSON-LD data island
13
+ const script = document.querySelector('script[type="application/ld+json"]#profile') ||
14
+ document.querySelector('script[type="application/ld+json"]')
15
+
16
+ if (!script) {
17
+ console.warn('Microfed Profile: No JSON-LD data island found')
18
+ return
19
+ }
20
+
21
+ let actor
22
+ try {
23
+ actor = JSON.parse(script.textContent)
24
+ } catch (e) {
25
+ console.error('Microfed Profile: Invalid JSON-LD', e)
26
+ return
27
+ }
28
+
29
+ // Find or create container
30
+ let container = document.getElementById('microfed-profile')
31
+ if (!container) {
32
+ container = document.createElement('div')
33
+ container.id = 'microfed-profile'
34
+ script.parentNode.insertBefore(container, script.nextSibling)
35
+ }
36
+
37
+ render(container, actor)
38
+ }
39
+
40
+ function render(container, actor) {
41
+ const name = actor.name || actor.preferredUsername || 'Unknown'
42
+ const username = actor.preferredUsername || ''
43
+ const summary = actor.summary || ''
44
+ const icon = actor.icon?.url || actor.icon || ''
45
+ const followers = actor.followers || ''
46
+ const following = actor.following || ''
47
+
48
+ container.innerHTML = `
49
+ <style>
50
+ .mf-profile {
51
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
52
+ max-width: 400px;
53
+ border: 1px solid #e1e4e8;
54
+ border-radius: 8px;
55
+ padding: 20px;
56
+ background: #fff;
57
+ }
58
+ .mf-profile-header {
59
+ display: flex;
60
+ align-items: center;
61
+ gap: 16px;
62
+ margin-bottom: 12px;
63
+ }
64
+ .mf-profile-avatar {
65
+ width: 64px;
66
+ height: 64px;
67
+ border-radius: 50%;
68
+ object-fit: cover;
69
+ background: #e1e4e8;
70
+ }
71
+ .mf-profile-avatar-placeholder {
72
+ width: 64px;
73
+ height: 64px;
74
+ border-radius: 50%;
75
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
76
+ display: flex;
77
+ align-items: center;
78
+ justify-content: center;
79
+ color: white;
80
+ font-size: 24px;
81
+ font-weight: bold;
82
+ }
83
+ .mf-profile-name {
84
+ font-size: 1.25rem;
85
+ font-weight: 600;
86
+ margin: 0;
87
+ color: #24292e;
88
+ }
89
+ .mf-profile-username {
90
+ font-size: 0.875rem;
91
+ color: #586069;
92
+ margin: 2px 0 0 0;
93
+ }
94
+ .mf-profile-summary {
95
+ color: #24292e;
96
+ line-height: 1.5;
97
+ margin: 12px 0;
98
+ }
99
+ .mf-profile-stats {
100
+ display: flex;
101
+ gap: 16px;
102
+ margin-top: 12px;
103
+ padding-top: 12px;
104
+ border-top: 1px solid #e1e4e8;
105
+ }
106
+ .mf-profile-stat {
107
+ color: #586069;
108
+ font-size: 0.875rem;
109
+ text-decoration: none;
110
+ }
111
+ .mf-profile-stat:hover {
112
+ color: #0366d6;
113
+ }
114
+ .mf-profile-stat strong {
115
+ color: #24292e;
116
+ }
117
+ </style>
118
+ <div class="mf-profile">
119
+ <div class="mf-profile-header">
120
+ ${icon
121
+ ? `<img class="mf-profile-avatar" src="${escapeHtml(icon)}" alt="${escapeHtml(name)}">`
122
+ : `<div class="mf-profile-avatar-placeholder">${escapeHtml(name.charAt(0).toUpperCase())}</div>`
123
+ }
124
+ <div>
125
+ <h2 class="mf-profile-name">${escapeHtml(name)}</h2>
126
+ ${username ? `<p class="mf-profile-username">@${escapeHtml(username)}</p>` : ''}
127
+ </div>
128
+ </div>
129
+ ${summary ? `<div class="mf-profile-summary">${summary}</div>` : ''}
130
+ <div class="mf-profile-stats">
131
+ ${followers ? `<a class="mf-profile-stat" href="${escapeHtml(followers)}"><strong>Followers</strong></a>` : ''}
132
+ ${following ? `<a class="mf-profile-stat" href="${escapeHtml(following)}"><strong>Following</strong></a>` : ''}
133
+ </div>
134
+ </div>
135
+ `
136
+ }
137
+
138
+ function escapeHtml(str) {
139
+ if (!str) return ''
140
+ const div = document.createElement('div')
141
+ div.textContent = str
142
+ return div.innerHTML
143
+ }
144
+
145
+ // Initialize when DOM is ready
146
+ if (document.readyState === 'loading') {
147
+ document.addEventListener('DOMContentLoaded', init)
148
+ } else {
149
+ init()
150
+ }
151
+ })()
@@ -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
+ })