javascript-solid-server 0.0.60 → 0.0.61
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/.claude/settings.local.json +3 -1
- package/package.json +8 -2
- package/scripts/install-termux.sh +81 -0
- package/src/ap/index.js +177 -0
- package/src/ap/keys.js +64 -0
- package/src/ap/routes/actor.js +54 -0
- package/src/ap/routes/collections.js +46 -0
- package/src/ap/routes/inbox.js +239 -0
- package/src/ap/routes/outbox.js +52 -0
- package/src/ap/store.js +228 -0
- package/src/server.js +30 -1
|
@@ -213,7 +213,9 @@
|
|
|
213
213
|
"Bash(ssh phone:*)",
|
|
214
214
|
"Bash(dig:*)",
|
|
215
215
|
"WebFetch(domain:fonstr.com)",
|
|
216
|
-
"Bash(node -e \"import\\(''nostr-tools''\\).then\\(m => console.log\\(Object.keys\\(m\\).join\\(''\\\\n''\\)\\)\\)\":*)"
|
|
216
|
+
"Bash(node -e \"import\\(''nostr-tools''\\).then\\(m => console.log\\(Object.keys\\(m\\).join\\(''\\\\n''\\)\\)\\)\":*)",
|
|
217
|
+
"Bash(gh repo list:*)",
|
|
218
|
+
"Bash(gh search:*)"
|
|
217
219
|
]
|
|
218
220
|
}
|
|
219
221
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javascript-solid-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.61",
|
|
4
4
|
"description": "A minimal, fast Solid server",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -27,10 +27,13 @@
|
|
|
27
27
|
"@fastify/rate-limit": "^9.1.0",
|
|
28
28
|
"@fastify/websocket": "^8.3.1",
|
|
29
29
|
"bcrypt": "^6.0.0",
|
|
30
|
+
"bcryptjs": "^3.0.3",
|
|
31
|
+
"better-sqlite3": "^12.5.0",
|
|
30
32
|
"commander": "^14.0.2",
|
|
31
33
|
"fastify": "^4.29.1",
|
|
32
34
|
"fs-extra": "^11.2.0",
|
|
33
35
|
"jose": "^6.1.3",
|
|
36
|
+
"microfed": "^0.0.14",
|
|
34
37
|
"n3": "^1.26.0",
|
|
35
38
|
"nostr-tools": "^2.19.4",
|
|
36
39
|
"oidc-provider": "^9.6.0"
|
|
@@ -42,7 +45,10 @@
|
|
|
42
45
|
"solid",
|
|
43
46
|
"ldp",
|
|
44
47
|
"linked-data",
|
|
45
|
-
"decentralized"
|
|
48
|
+
"decentralized",
|
|
49
|
+
"activitypub",
|
|
50
|
+
"fediverse",
|
|
51
|
+
"nostr"
|
|
46
52
|
],
|
|
47
53
|
"license": "AGPL-3.0-only",
|
|
48
54
|
"devDependencies": {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/data/data/com.termux/files/usr/bin/bash
|
|
2
|
+
#
|
|
3
|
+
# PhonePod Installer for Termux
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# curl -sL https://raw.githubusercontent.com/JavaScriptSolidServer/JavaScriptSolidServer/gh-pages/scripts/install-termux.sh | bash
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
set -e
|
|
10
|
+
|
|
11
|
+
echo ""
|
|
12
|
+
echo " ╔═══════════════════════════════════════╗"
|
|
13
|
+
echo " ║ PhonePod Installer ║"
|
|
14
|
+
echo " ║ Solid + Nostr + Git on your phone ║"
|
|
15
|
+
echo " ╚═══════════════════════════════════════╝"
|
|
16
|
+
echo ""
|
|
17
|
+
|
|
18
|
+
# Check we're in Termux
|
|
19
|
+
if [ ! -d "/data/data/com.termux" ]; then
|
|
20
|
+
echo "✗ This script is for Termux on Android"
|
|
21
|
+
echo " Install Termux from F-Droid: https://f-droid.org/packages/com.termux/"
|
|
22
|
+
exit 1
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
echo "→ Installing dependencies..."
|
|
26
|
+
pkg update -y
|
|
27
|
+
pkg install -y nodejs-lts openssh autossh git
|
|
28
|
+
|
|
29
|
+
echo "→ Installing PM2 and JSS..."
|
|
30
|
+
npm install -g pm2 javascript-solid-server
|
|
31
|
+
|
|
32
|
+
# Fix PATH for npm global bins
|
|
33
|
+
NPM_BIN="$(npm config get prefix)/bin"
|
|
34
|
+
if [[ ":$PATH:" != *":$NPM_BIN:"* ]]; then
|
|
35
|
+
echo "export PATH=\"\$PATH:$NPM_BIN\"" >> ~/.bashrc
|
|
36
|
+
export PATH="$PATH:$NPM_BIN"
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
echo "→ Setting up boot persistence..."
|
|
40
|
+
mkdir -p ~/.termux/boot
|
|
41
|
+
cat > ~/.termux/boot/start-pod.sh << 'BOOT'
|
|
42
|
+
#!/data/data/com.termux/files/usr/bin/bash
|
|
43
|
+
# Start PhonePod on boot
|
|
44
|
+
termux-wake-lock
|
|
45
|
+
export PATH="$PATH:$(npm config get prefix)/bin"
|
|
46
|
+
pm2 resurrect
|
|
47
|
+
BOOT
|
|
48
|
+
chmod +x ~/.termux/boot/start-pod.sh
|
|
49
|
+
|
|
50
|
+
echo "→ Starting JSS..."
|
|
51
|
+
pm2 start jss -- start --port 8080 --nostr --git
|
|
52
|
+
pm2 save
|
|
53
|
+
|
|
54
|
+
# Get local IP
|
|
55
|
+
LOCAL_IP=$(ip route get 1 2>/dev/null | awk '{print $7}' | head -1)
|
|
56
|
+
|
|
57
|
+
echo ""
|
|
58
|
+
echo " ╔═══════════════════════════════════════╗"
|
|
59
|
+
echo " ║ ✓ PhonePod Installed! ║"
|
|
60
|
+
echo " ╚═══════════════════════════════════════╝"
|
|
61
|
+
echo ""
|
|
62
|
+
echo " Local: http://localhost:8080"
|
|
63
|
+
if [ -n "$LOCAL_IP" ]; then
|
|
64
|
+
echo " Network: http://$LOCAL_IP:8080"
|
|
65
|
+
fi
|
|
66
|
+
echo ""
|
|
67
|
+
echo " Features enabled:"
|
|
68
|
+
echo " • Solid pod (LDP, WAC, WebID)"
|
|
69
|
+
echo " • Nostr relay (wss://localhost:8080/relay)"
|
|
70
|
+
echo " • Git server (git clone http://localhost:8080/)"
|
|
71
|
+
echo ""
|
|
72
|
+
echo " Commands:"
|
|
73
|
+
echo " pm2 status - check status"
|
|
74
|
+
echo " pm2 logs jss - view logs"
|
|
75
|
+
echo " pm2 restart jss - restart server"
|
|
76
|
+
echo ""
|
|
77
|
+
echo " For public access, setup a tunnel:"
|
|
78
|
+
echo " https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/46"
|
|
79
|
+
echo ""
|
|
80
|
+
echo " NOTE: Install Termux:Boot from F-Droid for auto-start on reboot"
|
|
81
|
+
echo ""
|
package/src/ap/index.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActivityPub Plugin for JSS
|
|
3
|
+
* Adds federation support via the ActivityPub protocol
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { webfinger } from 'microfed'
|
|
7
|
+
import { loadOrCreateKeypair, getKeyId } from './keys.js'
|
|
8
|
+
import { initStore } from './store.js'
|
|
9
|
+
import { createInboxHandler } from './routes/inbox.js'
|
|
10
|
+
import { createOutboxHandler } from './routes/outbox.js'
|
|
11
|
+
import { createCollectionsHandler } from './routes/collections.js'
|
|
12
|
+
import { createActorHandler } from './routes/actor.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* ActivityPub Fastify plugin
|
|
16
|
+
* @param {FastifyInstance} fastify
|
|
17
|
+
* @param {object} options
|
|
18
|
+
* @param {string} options.username - Default username for single-user mode
|
|
19
|
+
* @param {string} options.displayName - Display name
|
|
20
|
+
* @param {string} options.summary - Bio/description
|
|
21
|
+
* @param {string} options.nostrPubkey - Nostr public key (hex) for identity linking
|
|
22
|
+
*/
|
|
23
|
+
export async function activityPubPlugin(fastify, options = {}) {
|
|
24
|
+
// Initialize storage and keypair
|
|
25
|
+
const keypair = loadOrCreateKeypair()
|
|
26
|
+
initStore()
|
|
27
|
+
|
|
28
|
+
// Store config for handlers
|
|
29
|
+
const config = {
|
|
30
|
+
keypair,
|
|
31
|
+
username: options.username || 'me',
|
|
32
|
+
displayName: options.displayName || options.username || 'Anonymous',
|
|
33
|
+
summary: options.summary || '',
|
|
34
|
+
nostrPubkey: options.nostrPubkey || null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Decorate fastify with AP config
|
|
38
|
+
fastify.decorate('apConfig', config)
|
|
39
|
+
|
|
40
|
+
// Helper to build actor ID from request
|
|
41
|
+
const getActorId = (request) => {
|
|
42
|
+
const protocol = request.headers['x-forwarded-proto'] || request.protocol
|
|
43
|
+
const host = request.headers['x-forwarded-host'] || request.hostname
|
|
44
|
+
return `${protocol}://${host}/profile/card#me`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Helper to get base URL
|
|
48
|
+
const getBaseUrl = (request) => {
|
|
49
|
+
const protocol = request.headers['x-forwarded-proto'] || request.protocol
|
|
50
|
+
const host = request.headers['x-forwarded-host'] || request.hostname
|
|
51
|
+
return `${protocol}://${host}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// WebFinger endpoint
|
|
55
|
+
fastify.get('/.well-known/webfinger', async (request, reply) => {
|
|
56
|
+
const resource = request.query.resource
|
|
57
|
+
if (!resource) {
|
|
58
|
+
return reply.code(400).send({ error: 'Missing resource parameter' })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const parsed = webfinger.parseResource(resource)
|
|
62
|
+
if (!parsed) {
|
|
63
|
+
return reply.code(400).send({ error: 'Invalid resource format' })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Check if this is our user
|
|
67
|
+
const host = request.headers['x-forwarded-host'] || request.hostname
|
|
68
|
+
if (parsed.domain !== host) {
|
|
69
|
+
return reply.code(404).send({ error: 'Not found' })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// For now, accept any username and map to /profile/card#me
|
|
73
|
+
// In multi-user mode, we'd look up the user
|
|
74
|
+
const baseUrl = getBaseUrl(request)
|
|
75
|
+
const actorUrl = `${baseUrl}/profile/card#me`
|
|
76
|
+
const profileUrl = `${baseUrl}/profile/card`
|
|
77
|
+
|
|
78
|
+
const response = webfinger.createResponse(
|
|
79
|
+
`${parsed.username}@${parsed.domain}`,
|
|
80
|
+
actorUrl,
|
|
81
|
+
{ profileUrl }
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return reply
|
|
85
|
+
.header('Content-Type', 'application/jrd+json')
|
|
86
|
+
.header('Access-Control-Allow-Origin', '*')
|
|
87
|
+
.send(response)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// NodeInfo discovery (for Mastodon compatibility)
|
|
91
|
+
fastify.get('/.well-known/nodeinfo', async (request, reply) => {
|
|
92
|
+
const baseUrl = getBaseUrl(request)
|
|
93
|
+
return reply
|
|
94
|
+
.header('Content-Type', 'application/json')
|
|
95
|
+
.send({
|
|
96
|
+
links: [
|
|
97
|
+
{
|
|
98
|
+
rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
|
|
99
|
+
href: `${baseUrl}/.well-known/nodeinfo/2.1`
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
fastify.get('/.well-known/nodeinfo/2.1', async (request, reply) => {
|
|
106
|
+
const { getPostCount } = await import('./store.js')
|
|
107
|
+
return reply
|
|
108
|
+
.header('Content-Type', 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"')
|
|
109
|
+
.send({
|
|
110
|
+
version: '2.1',
|
|
111
|
+
software: {
|
|
112
|
+
name: 'jss',
|
|
113
|
+
version: '0.0.61',
|
|
114
|
+
repository: 'https://github.com/JavaScriptSolidServer/JavaScriptSolidServer'
|
|
115
|
+
},
|
|
116
|
+
protocols: ['activitypub', 'solid'],
|
|
117
|
+
services: { inbound: [], outbound: [] },
|
|
118
|
+
usage: {
|
|
119
|
+
users: { total: 1, activeMonth: 1, activeHalfyear: 1 },
|
|
120
|
+
localPosts: getPostCount()
|
|
121
|
+
},
|
|
122
|
+
openRegistrations: true,
|
|
123
|
+
metadata: {
|
|
124
|
+
nodeName: config.displayName,
|
|
125
|
+
nodeDescription: 'SAND Stack: Solid + ActivityPub + Nostr + DID'
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// Actor endpoint - handle AP content negotiation for /profile/card
|
|
131
|
+
const actorHandler = createActorHandler(config, keypair)
|
|
132
|
+
|
|
133
|
+
// Decorate request to track AP handling
|
|
134
|
+
fastify.decorateRequest('apHandled', false)
|
|
135
|
+
|
|
136
|
+
// Register dedicated GET route for /profile/card with AP content negotiation
|
|
137
|
+
// This needs to run BEFORE the wildcard LDP routes
|
|
138
|
+
fastify.get('/profile/card', {
|
|
139
|
+
// Run this handler first, before wildcard routes
|
|
140
|
+
preHandler: async (request, reply) => {
|
|
141
|
+
const accept = request.headers.accept || ''
|
|
142
|
+
const wantsAP = accept.includes('activity+json') ||
|
|
143
|
+
accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"')
|
|
144
|
+
|
|
145
|
+
if (wantsAP) {
|
|
146
|
+
const actor = actorHandler(request)
|
|
147
|
+
request.apHandled = true
|
|
148
|
+
return reply
|
|
149
|
+
.header('Content-Type', 'application/activity+json')
|
|
150
|
+
.send(actor)
|
|
151
|
+
}
|
|
152
|
+
// If not AP, skip and let the request continue (but this route won't have a main handler)
|
|
153
|
+
// We return early - the request will 404 on this route but get caught by wildcard
|
|
154
|
+
}
|
|
155
|
+
}, async (request, reply) => {
|
|
156
|
+
// This handler won't be reached if AP was handled
|
|
157
|
+
// For non-AP requests, we need to pass through to LDP
|
|
158
|
+
// But we can't easily do that here, so we'll handle it differently
|
|
159
|
+
reply.callNotFound()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// Inbox endpoint
|
|
163
|
+
const inboxHandler = createInboxHandler(config, keypair)
|
|
164
|
+
fastify.post('/inbox', inboxHandler)
|
|
165
|
+
fastify.post('/profile/card/inbox', inboxHandler)
|
|
166
|
+
|
|
167
|
+
// Outbox endpoint
|
|
168
|
+
const outboxHandler = createOutboxHandler(config, keypair)
|
|
169
|
+
fastify.get('/profile/card/outbox', outboxHandler)
|
|
170
|
+
|
|
171
|
+
// Followers/Following collections
|
|
172
|
+
const collectionsHandler = createCollectionsHandler(config)
|
|
173
|
+
fastify.get('/profile/card/followers', (req, reply) => collectionsHandler(req, reply, 'followers'))
|
|
174
|
+
fastify.get('/profile/card/following', (req, reply) => collectionsHandler(req, reply, 'following'))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export default activityPubPlugin
|
package/src/ap/keys.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActivityPub RSA Keypair Management
|
|
3
|
+
* Generate and persist keypairs for HTTP Signatures
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { generateKeyPairSync } from 'crypto'
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
|
|
8
|
+
import { dirname, join } from 'path'
|
|
9
|
+
|
|
10
|
+
const DEFAULT_KEY_PATH = 'data/ap-keys.json'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generate RSA keypair
|
|
14
|
+
* @param {number} modulusLength - Key size in bits (default 2048)
|
|
15
|
+
* @returns {{ publicKey: string, privateKey: string }}
|
|
16
|
+
*/
|
|
17
|
+
export function generateKeypair(modulusLength = 2048) {
|
|
18
|
+
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
|
19
|
+
modulusLength,
|
|
20
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
21
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
|
|
22
|
+
})
|
|
23
|
+
return { publicKey, privateKey }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load keypair from disk, generate if not exists
|
|
28
|
+
* @param {string} path - Path to keys file
|
|
29
|
+
* @returns {{ publicKey: string, privateKey: string }}
|
|
30
|
+
*/
|
|
31
|
+
export function loadOrCreateKeypair(path = DEFAULT_KEY_PATH) {
|
|
32
|
+
if (existsSync(path)) {
|
|
33
|
+
const data = JSON.parse(readFileSync(path, 'utf8'))
|
|
34
|
+
return data
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Generate new keypair
|
|
38
|
+
const keypair = generateKeypair()
|
|
39
|
+
|
|
40
|
+
// Ensure directory exists
|
|
41
|
+
const dir = dirname(path)
|
|
42
|
+
if (!existsSync(dir)) {
|
|
43
|
+
mkdirSync(dir, { recursive: true })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Save to disk
|
|
47
|
+
writeFileSync(path, JSON.stringify(keypair, null, 2))
|
|
48
|
+
console.log(`Generated new ActivityPub keypair: ${path}`)
|
|
49
|
+
|
|
50
|
+
return keypair
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get key ID for HTTP Signatures
|
|
55
|
+
* @param {string} actorId - Actor URL (e.g., https://example.com/profile/card#me)
|
|
56
|
+
* @returns {string} Key ID (e.g., https://example.com/profile/card#main-key)
|
|
57
|
+
*/
|
|
58
|
+
export function getKeyId(actorId) {
|
|
59
|
+
// Strip fragment and add #main-key
|
|
60
|
+
const base = actorId.replace(/#.*$/, '')
|
|
61
|
+
return `${base}#main-key`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default { generateKeypair, loadOrCreateKeypair, getKeyId }
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actor endpoint handler
|
|
3
|
+
* Returns ActivityPub Actor JSON-LD for content negotiation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create actor handler
|
|
8
|
+
* @param {object} config - AP configuration
|
|
9
|
+
* @param {object} keypair - RSA keypair
|
|
10
|
+
* @returns {Function} Handler function
|
|
11
|
+
*/
|
|
12
|
+
export function createActorHandler(config, keypair) {
|
|
13
|
+
return (request) => {
|
|
14
|
+
const protocol = request.headers['x-forwarded-proto'] || request.protocol
|
|
15
|
+
const host = request.headers['x-forwarded-host'] || request.hostname
|
|
16
|
+
const baseUrl = `${protocol}://${host}`
|
|
17
|
+
const profileUrl = `${baseUrl}/profile/card`
|
|
18
|
+
const actorId = `${profileUrl}#me`
|
|
19
|
+
|
|
20
|
+
const actor = {
|
|
21
|
+
'@context': [
|
|
22
|
+
'https://www.w3.org/ns/activitystreams',
|
|
23
|
+
'https://w3id.org/security/v1'
|
|
24
|
+
],
|
|
25
|
+
type: 'Person',
|
|
26
|
+
id: actorId,
|
|
27
|
+
url: profileUrl,
|
|
28
|
+
preferredUsername: config.username,
|
|
29
|
+
name: config.displayName,
|
|
30
|
+
summary: config.summary ? `<p>${config.summary}</p>` : '',
|
|
31
|
+
inbox: `${profileUrl}/inbox`,
|
|
32
|
+
outbox: `${profileUrl}/outbox`,
|
|
33
|
+
followers: `${profileUrl}/followers`,
|
|
34
|
+
following: `${profileUrl}/following`,
|
|
35
|
+
endpoints: {
|
|
36
|
+
sharedInbox: `${baseUrl}/inbox`
|
|
37
|
+
},
|
|
38
|
+
publicKey: {
|
|
39
|
+
id: `${profileUrl}#main-key`,
|
|
40
|
+
owner: actorId,
|
|
41
|
+
publicKeyPem: keypair.publicKey
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Add Nostr identity linking via alsoKnownAs
|
|
46
|
+
if (config.nostrPubkey) {
|
|
47
|
+
actor.alsoKnownAs = [`did:nostr:${config.nostrPubkey}`]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return actor
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default { createActorHandler }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collections endpoint handler
|
|
3
|
+
* Returns followers/following as OrderedCollection
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getFollowers, getFollowing, getFollowerCount, getFollowingCount } from '../store.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create collections handler
|
|
10
|
+
* @param {object} config - AP configuration
|
|
11
|
+
* @returns {Function} Fastify handler
|
|
12
|
+
*/
|
|
13
|
+
export function createCollectionsHandler(config) {
|
|
14
|
+
return async (request, reply, collectionType) => {
|
|
15
|
+
const protocol = request.headers['x-forwarded-proto'] || request.protocol
|
|
16
|
+
const host = request.headers['x-forwarded-host'] || request.hostname
|
|
17
|
+
const baseUrl = `${protocol}://${host}`
|
|
18
|
+
const profileUrl = `${baseUrl}/profile/card`
|
|
19
|
+
|
|
20
|
+
let items, totalItems
|
|
21
|
+
|
|
22
|
+
if (collectionType === 'followers') {
|
|
23
|
+
const followers = getFollowers()
|
|
24
|
+
items = followers.map(f => f.actor)
|
|
25
|
+
totalItems = getFollowerCount()
|
|
26
|
+
} else {
|
|
27
|
+
const following = getFollowing()
|
|
28
|
+
items = following.map(f => f.actor)
|
|
29
|
+
totalItems = getFollowingCount()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const collection = {
|
|
33
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
34
|
+
type: 'OrderedCollection',
|
|
35
|
+
id: `${profileUrl}/${collectionType}`,
|
|
36
|
+
totalItems,
|
|
37
|
+
orderedItems: items
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return reply
|
|
41
|
+
.header('Content-Type', 'application/activity+json')
|
|
42
|
+
.send(collection)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default { createCollectionsHandler }
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbox endpoint handler
|
|
3
|
+
* Receives and processes incoming ActivityPub activities
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { auth, outbox } from 'microfed'
|
|
7
|
+
import {
|
|
8
|
+
saveActivity,
|
|
9
|
+
addFollower,
|
|
10
|
+
removeFollower,
|
|
11
|
+
acceptFollowing,
|
|
12
|
+
cacheActor,
|
|
13
|
+
getCachedActor
|
|
14
|
+
} from '../store.js'
|
|
15
|
+
import { getKeyId } from '../keys.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fetch remote actor (with caching)
|
|
19
|
+
* @param {string} id - Actor URL
|
|
20
|
+
* @returns {Promise<object|null>} Actor object or null
|
|
21
|
+
*/
|
|
22
|
+
async function fetchActor(id) {
|
|
23
|
+
// Strip fragment for fetching
|
|
24
|
+
const fetchUrl = id.replace(/#.*$/, '')
|
|
25
|
+
const cached = getCachedActor(id)
|
|
26
|
+
if (cached) return cached
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const response = await fetch(fetchUrl, {
|
|
30
|
+
headers: { 'Accept': 'application/activity+json' }
|
|
31
|
+
})
|
|
32
|
+
if (!response.ok) return null
|
|
33
|
+
|
|
34
|
+
const actor = await response.json()
|
|
35
|
+
cacheActor(actor)
|
|
36
|
+
return actor
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Verify HTTP signature on incoming request
|
|
44
|
+
* @param {object} request - Fastify request
|
|
45
|
+
* @param {string} body - Request body
|
|
46
|
+
* @returns {Promise<{valid: boolean, actor?: object, reason?: string}>}
|
|
47
|
+
*/
|
|
48
|
+
async function verifySignature(request, body) {
|
|
49
|
+
const signature = request.headers['signature']
|
|
50
|
+
if (!signature) {
|
|
51
|
+
return { valid: false, reason: 'No signature header' }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Parse signature header
|
|
55
|
+
const sigParts = auth.parseSignatureHeader(signature)
|
|
56
|
+
if (!sigParts) {
|
|
57
|
+
return { valid: false, reason: 'Invalid signature format' }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const keyId = sigParts.keyId
|
|
61
|
+
if (!keyId) {
|
|
62
|
+
return { valid: false, reason: 'No keyId in signature' }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Extract actor URL from keyId (strip fragment like #main-key)
|
|
66
|
+
const actorUrl = keyId.replace(/#.*$/, '')
|
|
67
|
+
|
|
68
|
+
// Fetch the actor to get their public key
|
|
69
|
+
const remoteActor = await fetchActor(actorUrl)
|
|
70
|
+
if (!remoteActor) {
|
|
71
|
+
return { valid: false, reason: `Could not fetch actor: ${actorUrl}` }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const publicKeyPem = remoteActor.publicKey?.publicKeyPem
|
|
75
|
+
if (!publicKeyPem) {
|
|
76
|
+
return { valid: false, reason: 'Actor has no public key' }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Verify digest if present
|
|
80
|
+
const digestHeader = request.headers['digest']
|
|
81
|
+
if (digestHeader && !auth.verifyDigest(body, digestHeader)) {
|
|
82
|
+
return { valid: false, reason: 'Digest mismatch' }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Build path from URL
|
|
86
|
+
const url = new URL(request.url, `http://${request.hostname}`)
|
|
87
|
+
|
|
88
|
+
// Verify signature
|
|
89
|
+
try {
|
|
90
|
+
const valid = auth.verify({
|
|
91
|
+
publicKey: publicKeyPem,
|
|
92
|
+
signature,
|
|
93
|
+
method: request.method,
|
|
94
|
+
path: url.pathname,
|
|
95
|
+
headers: request.headers
|
|
96
|
+
})
|
|
97
|
+
return { valid, actor: remoteActor }
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return { valid: false, reason: `Verification error: ${err.message}` }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create inbox handler
|
|
105
|
+
* @param {object} config - AP configuration
|
|
106
|
+
* @param {object} keypair - RSA keypair
|
|
107
|
+
* @returns {Function} Fastify handler
|
|
108
|
+
*/
|
|
109
|
+
export function createInboxHandler(config, keypair) {
|
|
110
|
+
return async (request, reply) => {
|
|
111
|
+
// Parse body
|
|
112
|
+
let activity
|
|
113
|
+
let body
|
|
114
|
+
try {
|
|
115
|
+
body = typeof request.body === 'string'
|
|
116
|
+
? request.body
|
|
117
|
+
: request.body.toString()
|
|
118
|
+
activity = JSON.parse(body)
|
|
119
|
+
} catch {
|
|
120
|
+
return reply.code(400).send({ error: 'Invalid JSON' })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Verify signature (log but don't reject for now - many servers have issues)
|
|
124
|
+
const sigResult = await verifySignature(request, body)
|
|
125
|
+
if (!sigResult.valid) {
|
|
126
|
+
request.log.warn(`Signature verification failed: ${sigResult.reason}`)
|
|
127
|
+
} else {
|
|
128
|
+
request.log.info(`Signature verified for ${activity.actor}`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Validate activity
|
|
132
|
+
if (!activity.type) {
|
|
133
|
+
return reply.code(400).send({ error: 'Missing activity type' })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Save activity
|
|
137
|
+
if (activity.id) {
|
|
138
|
+
saveActivity(activity)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Handle activity by type
|
|
142
|
+
const protocol = request.headers['x-forwarded-proto'] || request.protocol
|
|
143
|
+
const host = request.headers['x-forwarded-host'] || request.hostname
|
|
144
|
+
const baseUrl = `${protocol}://${host}`
|
|
145
|
+
const profileUrl = `${baseUrl}/profile/card`
|
|
146
|
+
const actorId = `${profileUrl}#me`
|
|
147
|
+
|
|
148
|
+
request.log.info(`Received ${activity.type} from ${activity.actor}`)
|
|
149
|
+
|
|
150
|
+
switch (activity.type) {
|
|
151
|
+
case 'Follow':
|
|
152
|
+
await handleFollow(activity, actorId, profileUrl, keypair, request.log)
|
|
153
|
+
break
|
|
154
|
+
|
|
155
|
+
case 'Undo':
|
|
156
|
+
await handleUndo(activity, request.log)
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
case 'Accept':
|
|
160
|
+
handleAccept(activity, request.log)
|
|
161
|
+
break
|
|
162
|
+
|
|
163
|
+
case 'Create':
|
|
164
|
+
request.log.info(`New post: ${activity.object?.content?.slice(0, 50)}...`)
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
case 'Like':
|
|
168
|
+
request.log.info(`Liked: ${activity.object}`)
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
case 'Announce':
|
|
172
|
+
request.log.info(`Boosted: ${activity.object}`)
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
default:
|
|
176
|
+
request.log.info(`Unhandled activity type: ${activity.type}`)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Accept the activity
|
|
180
|
+
return reply.code(202).send()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Handle Follow activity
|
|
186
|
+
*/
|
|
187
|
+
async function handleFollow(activity, actorId, profileUrl, keypair, log) {
|
|
188
|
+
const followerActor = await fetchActor(activity.actor)
|
|
189
|
+
if (!followerActor) {
|
|
190
|
+
log.warn('Could not fetch follower actor')
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Add to followers
|
|
195
|
+
addFollower(activity.actor, followerActor.inbox)
|
|
196
|
+
log.info(`New follower: ${followerActor.preferredUsername || activity.actor}`)
|
|
197
|
+
|
|
198
|
+
// Send Accept
|
|
199
|
+
const accept = outbox.createAccept(actorId, activity)
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
await outbox.send({
|
|
203
|
+
activity: accept,
|
|
204
|
+
inbox: followerActor.inbox,
|
|
205
|
+
privateKey: keypair.privateKey,
|
|
206
|
+
keyId: `${profileUrl}#main-key`
|
|
207
|
+
})
|
|
208
|
+
log.info(`Sent Accept to ${followerActor.inbox}`)
|
|
209
|
+
} catch (err) {
|
|
210
|
+
log.error(`Failed to send Accept: ${err.message}`)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Handle Undo activity
|
|
216
|
+
*/
|
|
217
|
+
async function handleUndo(activity, log) {
|
|
218
|
+
if (activity.object?.type === 'Follow') {
|
|
219
|
+
removeFollower(activity.actor)
|
|
220
|
+
log.info(`Unfollowed by ${activity.actor}`)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Handle Accept activity (our follow was accepted)
|
|
226
|
+
*/
|
|
227
|
+
function handleAccept(activity, log) {
|
|
228
|
+
if (activity.object?.type === 'Follow') {
|
|
229
|
+
const target = typeof activity.object.object === 'string'
|
|
230
|
+
? activity.object.object
|
|
231
|
+
: activity.object.object?.id
|
|
232
|
+
if (target) {
|
|
233
|
+
acceptFollowing(target)
|
|
234
|
+
log.info('Follow accepted!')
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export default { createInboxHandler }
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbox endpoint handler
|
|
3
|
+
* Returns user's activities as OrderedCollection
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getPosts } from '../store.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create outbox handler
|
|
10
|
+
* @param {object} config - AP configuration
|
|
11
|
+
* @param {object} keypair - RSA keypair
|
|
12
|
+
* @returns {Function} Fastify handler
|
|
13
|
+
*/
|
|
14
|
+
export function createOutboxHandler(config, keypair) {
|
|
15
|
+
return async (request, reply) => {
|
|
16
|
+
const protocol = request.headers['x-forwarded-proto'] || request.protocol
|
|
17
|
+
const host = request.headers['x-forwarded-host'] || request.hostname
|
|
18
|
+
const baseUrl = `${protocol}://${host}`
|
|
19
|
+
const profileUrl = `${baseUrl}/profile/card`
|
|
20
|
+
const actorId = `${profileUrl}#me`
|
|
21
|
+
|
|
22
|
+
const posts = getPosts(20)
|
|
23
|
+
|
|
24
|
+
const collection = {
|
|
25
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
26
|
+
type: 'OrderedCollection',
|
|
27
|
+
id: `${profileUrl}/outbox`,
|
|
28
|
+
totalItems: posts.length,
|
|
29
|
+
orderedItems: posts.map(p => ({
|
|
30
|
+
type: 'Create',
|
|
31
|
+
actor: actorId,
|
|
32
|
+
published: p.published,
|
|
33
|
+
object: {
|
|
34
|
+
type: 'Note',
|
|
35
|
+
id: p.id,
|
|
36
|
+
content: p.content,
|
|
37
|
+
published: p.published,
|
|
38
|
+
attributedTo: actorId,
|
|
39
|
+
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
40
|
+
cc: [`${profileUrl}/followers`],
|
|
41
|
+
...(p.in_reply_to ? { inReplyTo: p.in_reply_to } : {})
|
|
42
|
+
}
|
|
43
|
+
}))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return reply
|
|
47
|
+
.header('Content-Type', 'application/activity+json')
|
|
48
|
+
.send(collection)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default { createOutboxHandler }
|
package/src/ap/store.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActivityPub SQLite Storage
|
|
3
|
+
* Persistence layer for federation data
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import Database from 'better-sqlite3'
|
|
7
|
+
import { existsSync, mkdirSync } from 'fs'
|
|
8
|
+
import { dirname } from 'path'
|
|
9
|
+
|
|
10
|
+
let db = null
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Initialize the database
|
|
14
|
+
* @param {string} path - Path to SQLite file
|
|
15
|
+
*/
|
|
16
|
+
export function initStore(path = 'data/activitypub.db') {
|
|
17
|
+
// Ensure directory exists
|
|
18
|
+
const dir = dirname(path)
|
|
19
|
+
if (!existsSync(dir)) {
|
|
20
|
+
mkdirSync(dir, { recursive: true })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
db = new Database(path)
|
|
24
|
+
|
|
25
|
+
// Create tables
|
|
26
|
+
db.exec(`
|
|
27
|
+
-- Followers (people following us)
|
|
28
|
+
CREATE TABLE IF NOT EXISTS followers (
|
|
29
|
+
id TEXT PRIMARY KEY,
|
|
30
|
+
actor TEXT NOT NULL,
|
|
31
|
+
inbox TEXT,
|
|
32
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
-- Following (people we follow)
|
|
36
|
+
CREATE TABLE IF NOT EXISTS following (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
actor TEXT NOT NULL,
|
|
39
|
+
accepted INTEGER DEFAULT 0,
|
|
40
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
-- Activities (inbox)
|
|
44
|
+
CREATE TABLE IF NOT EXISTS activities (
|
|
45
|
+
id TEXT PRIMARY KEY,
|
|
46
|
+
type TEXT NOT NULL,
|
|
47
|
+
actor TEXT,
|
|
48
|
+
object TEXT,
|
|
49
|
+
raw TEXT NOT NULL,
|
|
50
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
-- Posts (our outbox)
|
|
54
|
+
CREATE TABLE IF NOT EXISTS posts (
|
|
55
|
+
id TEXT PRIMARY KEY,
|
|
56
|
+
content TEXT NOT NULL,
|
|
57
|
+
in_reply_to TEXT,
|
|
58
|
+
published TEXT DEFAULT CURRENT_TIMESTAMP
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
-- Known actors (cache)
|
|
62
|
+
CREATE TABLE IF NOT EXISTS actors (
|
|
63
|
+
id TEXT PRIMARY KEY,
|
|
64
|
+
data TEXT NOT NULL,
|
|
65
|
+
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
66
|
+
);
|
|
67
|
+
`)
|
|
68
|
+
|
|
69
|
+
return db
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get database instance
|
|
74
|
+
*/
|
|
75
|
+
export function getStore() {
|
|
76
|
+
if (!db) {
|
|
77
|
+
throw new Error('Store not initialized. Call initStore() first.')
|
|
78
|
+
}
|
|
79
|
+
return db
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Followers
|
|
83
|
+
|
|
84
|
+
export function addFollower(actorId, inbox) {
|
|
85
|
+
const stmt = db.prepare(`
|
|
86
|
+
INSERT OR REPLACE INTO followers (id, actor, inbox)
|
|
87
|
+
VALUES (?, ?, ?)
|
|
88
|
+
`)
|
|
89
|
+
stmt.run(actorId, actorId, inbox)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function removeFollower(actorId) {
|
|
93
|
+
const stmt = db.prepare('DELETE FROM followers WHERE id = ?')
|
|
94
|
+
stmt.run(actorId)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getFollowers() {
|
|
98
|
+
const stmt = db.prepare('SELECT * FROM followers ORDER BY created_at DESC')
|
|
99
|
+
return stmt.all()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getFollowerCount() {
|
|
103
|
+
const stmt = db.prepare('SELECT COUNT(*) as count FROM followers')
|
|
104
|
+
return stmt.get().count
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function getFollowerInboxes() {
|
|
108
|
+
const stmt = db.prepare('SELECT DISTINCT inbox FROM followers WHERE inbox IS NOT NULL')
|
|
109
|
+
return stmt.all().map(row => row.inbox)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Following
|
|
113
|
+
|
|
114
|
+
export function addFollowing(actorId, accepted = false) {
|
|
115
|
+
const stmt = db.prepare(`
|
|
116
|
+
INSERT OR REPLACE INTO following (id, actor, accepted)
|
|
117
|
+
VALUES (?, ?, ?)
|
|
118
|
+
`)
|
|
119
|
+
stmt.run(actorId, actorId, accepted ? 1 : 0)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function acceptFollowing(actorId) {
|
|
123
|
+
const stmt = db.prepare('UPDATE following SET accepted = 1 WHERE id = ?')
|
|
124
|
+
stmt.run(actorId)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function removeFollowing(actorId) {
|
|
128
|
+
const stmt = db.prepare('DELETE FROM following WHERE id = ?')
|
|
129
|
+
stmt.run(actorId)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function getFollowing() {
|
|
133
|
+
const stmt = db.prepare('SELECT * FROM following WHERE accepted = 1 ORDER BY created_at DESC')
|
|
134
|
+
return stmt.all()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function getFollowingCount() {
|
|
138
|
+
const stmt = db.prepare('SELECT COUNT(*) as count FROM following WHERE accepted = 1')
|
|
139
|
+
return stmt.get().count
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Activities
|
|
143
|
+
|
|
144
|
+
export function saveActivity(activity) {
|
|
145
|
+
const stmt = db.prepare(`
|
|
146
|
+
INSERT OR REPLACE INTO activities (id, type, actor, object, raw)
|
|
147
|
+
VALUES (?, ?, ?, ?, ?)
|
|
148
|
+
`)
|
|
149
|
+
stmt.run(
|
|
150
|
+
activity.id,
|
|
151
|
+
activity.type,
|
|
152
|
+
typeof activity.actor === 'string' ? activity.actor : activity.actor?.id,
|
|
153
|
+
typeof activity.object === 'string' ? activity.object : JSON.stringify(activity.object),
|
|
154
|
+
JSON.stringify(activity)
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function getActivities(limit = 20) {
|
|
159
|
+
const stmt = db.prepare('SELECT * FROM activities ORDER BY created_at DESC LIMIT ?')
|
|
160
|
+
return stmt.all(limit).map(row => ({
|
|
161
|
+
...row,
|
|
162
|
+
raw: JSON.parse(row.raw)
|
|
163
|
+
}))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Posts
|
|
167
|
+
|
|
168
|
+
export function savePost(id, content, inReplyTo = null) {
|
|
169
|
+
const stmt = db.prepare(`
|
|
170
|
+
INSERT INTO posts (id, content, in_reply_to)
|
|
171
|
+
VALUES (?, ?, ?)
|
|
172
|
+
`)
|
|
173
|
+
stmt.run(id, content, inReplyTo)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function getPosts(limit = 20) {
|
|
177
|
+
const stmt = db.prepare('SELECT * FROM posts ORDER BY published DESC LIMIT ?')
|
|
178
|
+
return stmt.all(limit)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function getPost(id) {
|
|
182
|
+
const stmt = db.prepare('SELECT * FROM posts WHERE id = ?')
|
|
183
|
+
return stmt.get(id)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function getPostCount() {
|
|
187
|
+
const stmt = db.prepare('SELECT COUNT(*) as count FROM posts')
|
|
188
|
+
return stmt.get().count
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Actor cache
|
|
192
|
+
|
|
193
|
+
export function cacheActor(actor) {
|
|
194
|
+
const stmt = db.prepare(`
|
|
195
|
+
INSERT OR REPLACE INTO actors (id, data, fetched_at)
|
|
196
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
197
|
+
`)
|
|
198
|
+
stmt.run(actor.id, JSON.stringify(actor))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function getCachedActor(id) {
|
|
202
|
+
const stmt = db.prepare('SELECT * FROM actors WHERE id = ?')
|
|
203
|
+
const row = stmt.get(id)
|
|
204
|
+
return row ? JSON.parse(row.data) : null
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export default {
|
|
208
|
+
initStore,
|
|
209
|
+
getStore,
|
|
210
|
+
addFollower,
|
|
211
|
+
removeFollower,
|
|
212
|
+
getFollowers,
|
|
213
|
+
getFollowerCount,
|
|
214
|
+
getFollowerInboxes,
|
|
215
|
+
addFollowing,
|
|
216
|
+
acceptFollowing,
|
|
217
|
+
removeFollowing,
|
|
218
|
+
getFollowing,
|
|
219
|
+
getFollowingCount,
|
|
220
|
+
saveActivity,
|
|
221
|
+
getActivities,
|
|
222
|
+
savePost,
|
|
223
|
+
getPosts,
|
|
224
|
+
getPost,
|
|
225
|
+
getPostCount,
|
|
226
|
+
cacheActor,
|
|
227
|
+
getCachedActor
|
|
228
|
+
}
|
package/src/server.js
CHANGED
|
@@ -12,6 +12,7 @@ import { idpPlugin } from './idp/index.js';
|
|
|
12
12
|
import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js';
|
|
13
13
|
import { AccessMode } from './wac/parser.js';
|
|
14
14
|
import { registerNostrRelay } from './nostr/relay.js';
|
|
15
|
+
import { activityPubPlugin } from './ap/index.js';
|
|
15
16
|
|
|
16
17
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
18
|
|
|
@@ -31,6 +32,11 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
31
32
|
* @param {boolean} options.nostr - Enable Nostr relay (default false)
|
|
32
33
|
* @param {string} options.nostrPath - Nostr relay WebSocket path (default '/relay')
|
|
33
34
|
* @param {number} options.nostrMaxEvents - Max events in relay memory (default 1000)
|
|
35
|
+
* @param {boolean} options.activitypub - Enable ActivityPub federation (default false)
|
|
36
|
+
* @param {string} options.apUsername - ActivityPub username (default 'me')
|
|
37
|
+
* @param {string} options.apDisplayName - ActivityPub display name
|
|
38
|
+
* @param {string} options.apSummary - ActivityPub bio/summary
|
|
39
|
+
* @param {string} options.apNostrPubkey - Nostr pubkey for identity linking
|
|
34
40
|
*/
|
|
35
41
|
export function createServer(options = {}) {
|
|
36
42
|
// Content negotiation is OFF by default - we're a JSON-LD native server
|
|
@@ -54,6 +60,12 @@ export function createServer(options = {}) {
|
|
|
54
60
|
const nostrEnabled = options.nostr ?? false;
|
|
55
61
|
const nostrPath = options.nostrPath ?? '/relay';
|
|
56
62
|
const nostrMaxEvents = options.nostrMaxEvents ?? 1000;
|
|
63
|
+
// ActivityPub federation is OFF by default
|
|
64
|
+
const activitypubEnabled = options.activitypub ?? false;
|
|
65
|
+
const apUsername = options.apUsername ?? 'me';
|
|
66
|
+
const apDisplayName = options.apDisplayName ?? options.apUsername ?? 'Anonymous';
|
|
67
|
+
const apSummary = options.apSummary ?? '';
|
|
68
|
+
const apNostrPubkey = options.apNostrPubkey ?? null;
|
|
57
69
|
// Invite-only registration is OFF by default - open registration
|
|
58
70
|
const inviteOnly = options.inviteOnly ?? false;
|
|
59
71
|
// Default storage quota per pod (50MB default, 0 = unlimited)
|
|
@@ -152,6 +164,16 @@ export function createServer(options = {}) {
|
|
|
152
164
|
});
|
|
153
165
|
}
|
|
154
166
|
|
|
167
|
+
// Register ActivityPub plugin if enabled
|
|
168
|
+
if (activitypubEnabled) {
|
|
169
|
+
fastify.register(activityPubPlugin, {
|
|
170
|
+
username: apUsername,
|
|
171
|
+
displayName: apDisplayName,
|
|
172
|
+
summary: apSummary,
|
|
173
|
+
nostrPubkey: apNostrPubkey
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
155
177
|
// Register rate limiting plugin
|
|
156
178
|
// Protects against brute force attacks and resource exhaustion
|
|
157
179
|
fastify.register(rateLimit, {
|
|
@@ -237,8 +259,13 @@ export function createServer(options = {}) {
|
|
|
237
259
|
// Authorization hook - check WAC permissions
|
|
238
260
|
// Skip for pod creation endpoint (needs special handling)
|
|
239
261
|
fastify.addHook('preHandler', async (request, reply) => {
|
|
240
|
-
// Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, notifications, nostr, and
|
|
262
|
+
// Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, notifications, nostr, git, and AP
|
|
241
263
|
const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
|
|
264
|
+
const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following'];
|
|
265
|
+
// Check if request wants ActivityPub content for profile
|
|
266
|
+
const accept = request.headers.accept || '';
|
|
267
|
+
const wantsAP = accept.includes('activity+json') || accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"');
|
|
268
|
+
const isProfileAP = activitypubEnabled && wantsAP && (request.url === '/profile/card' || request.url.startsWith('/profile/card?'));
|
|
242
269
|
if (request.url === '/.pods' ||
|
|
243
270
|
request.url === '/.notifications' ||
|
|
244
271
|
request.method === 'OPTIONS' ||
|
|
@@ -246,6 +273,8 @@ export function createServer(options = {}) {
|
|
|
246
273
|
request.url.startsWith('/.well-known/') ||
|
|
247
274
|
(nostrEnabled && request.url.startsWith(nostrPath)) ||
|
|
248
275
|
(gitEnabled && isGitRequest(request.url)) ||
|
|
276
|
+
(activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
|
|
277
|
+
isProfileAP ||
|
|
249
278
|
mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
|
|
250
279
|
return;
|
|
251
280
|
}
|