javascript-solid-server 0.0.61 → 0.0.64
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/README.md +86 -1
- package/bin/jss.js +12 -0
- package/package.json +3 -2
- package/src/ap/index.js +32 -33
- package/src/ap/routes/actor.js +17 -1
- package/src/ap/store.js +181 -108
- package/src/config.js +12 -0
- package/src/server.js +28 -1
|
@@ -215,7 +215,9 @@
|
|
|
215
215
|
"WebFetch(domain:fonstr.com)",
|
|
216
216
|
"Bash(node -e \"import\\(''nostr-tools''\\).then\\(m => console.log\\(Object.keys\\(m\\).join\\(''\\\\n''\\)\\)\\)\":*)",
|
|
217
217
|
"Bash(gh repo list:*)",
|
|
218
|
-
"Bash(gh search:*)"
|
|
218
|
+
"Bash(gh search:*)",
|
|
219
|
+
"Bash(__NEW_LINE__ echo \"\")",
|
|
220
|
+
"WebFetch(domain:webfinger.net)"
|
|
219
221
|
]
|
|
220
222
|
}
|
|
221
223
|
}
|
package/README.md
CHANGED
|
@@ -6,8 +6,9 @@ A minimal, fast, JSON-LD native Solid server.
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
### Implemented (v0.0.
|
|
9
|
+
### Implemented (v0.0.61)
|
|
10
10
|
|
|
11
|
+
- **ActivityPub Federation** - Fediverse integration with WebFinger, inbox/outbox, HTTP signatures
|
|
11
12
|
- **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
|
|
12
13
|
- **N3 Patch** - Solid's native patch format for RDF updates
|
|
13
14
|
- **SPARQL Update** - Standard SPARQL UPDATE protocol for PATCH
|
|
@@ -126,6 +127,11 @@ jss --help # Show help
|
|
|
126
127
|
| `--nostr-max-events <n>` | Max events in relay memory | 1000 |
|
|
127
128
|
| `--invite-only` | Require invite code for registration | false |
|
|
128
129
|
| `--default-quota <size>` | Default storage quota per pod (e.g., 50MB) | 50MB |
|
|
130
|
+
| `--activitypub` | Enable ActivityPub federation | false |
|
|
131
|
+
| `--ap-username <name>` | ActivityPub username | me |
|
|
132
|
+
| `--ap-display-name <name>` | ActivityPub display name | (username) |
|
|
133
|
+
| `--ap-summary <text>` | ActivityPub bio/summary | - |
|
|
134
|
+
| `--ap-nostr-pubkey <hex>` | Nostr pubkey for identity linking | - |
|
|
129
135
|
| `-q, --quiet` | Suppress logs | false |
|
|
130
136
|
|
|
131
137
|
### Environment Variables
|
|
@@ -143,6 +149,8 @@ export JSS_MASHLIB=true
|
|
|
143
149
|
export JSS_NOSTR=true
|
|
144
150
|
export JSS_INVITE_ONLY=true
|
|
145
151
|
export JSS_DEFAULT_QUOTA=100MB
|
|
152
|
+
export JSS_ACTIVITYPUB=true
|
|
153
|
+
export JSS_AP_USERNAME=alice
|
|
146
154
|
jss start
|
|
147
155
|
```
|
|
148
156
|
|
|
@@ -409,6 +417,72 @@ git add .acl && git commit -m "Add ACL"
|
|
|
409
417
|
|
|
410
418
|
See [git-credential-nostr](https://github.com/JavaScriptSolidServer/git-credential-nostr) for more details.
|
|
411
419
|
|
|
420
|
+
## ActivityPub Federation
|
|
421
|
+
|
|
422
|
+
Enable ActivityPub to federate with Mastodon, Pleroma, Misskey, and other Fediverse servers:
|
|
423
|
+
|
|
424
|
+
```bash
|
|
425
|
+
jss start --activitypub --ap-username alice --ap-display-name "Alice" --ap-summary "Hello from JSS!"
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
### Endpoints
|
|
429
|
+
|
|
430
|
+
| Endpoint | Description |
|
|
431
|
+
|----------|-------------|
|
|
432
|
+
| `/.well-known/webfinger` | Actor discovery (Mastodon searches here) |
|
|
433
|
+
| `/.well-known/nodeinfo` | NodeInfo discovery |
|
|
434
|
+
| `/profile/card` | Actor (returns JSON-LD when `Accept: application/activity+json`) |
|
|
435
|
+
| `/inbox` | Shared inbox for receiving activities |
|
|
436
|
+
| `/profile/card/inbox` | Personal inbox |
|
|
437
|
+
| `/profile/card/outbox` | User's activities |
|
|
438
|
+
| `/profile/card/followers` | Followers collection |
|
|
439
|
+
| `/profile/card/following` | Following collection |
|
|
440
|
+
|
|
441
|
+
### How It Works
|
|
442
|
+
|
|
443
|
+
1. **Discovery**: Mastodon looks up `@alice@your.server` via WebFinger
|
|
444
|
+
2. **Actor**: Returns ActivityPub Actor JSON-LD with public key
|
|
445
|
+
3. **Follow**: Remote servers POST Follow activities to inbox
|
|
446
|
+
4. **Accept**: JSS auto-accepts follows and sends Accept back
|
|
447
|
+
5. **Delivery**: Posts are signed with HTTP Signatures and delivered to follower inboxes
|
|
448
|
+
|
|
449
|
+
### Identity Linking
|
|
450
|
+
|
|
451
|
+
Your WebID (`/profile/card#me`) becomes your ActivityPub Actor. Link to Nostr identity:
|
|
452
|
+
|
|
453
|
+
```bash
|
|
454
|
+
jss start --activitypub --ap-nostr-pubkey <64-char-hex-pubkey>
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
This adds `alsoKnownAs: ["did:nostr:<pubkey>"]` to your Actor profile, creating a verifiable link between your Solid, ActivityPub, and Nostr identities (the SAND stack).
|
|
458
|
+
|
|
459
|
+
### Programmatic Usage
|
|
460
|
+
|
|
461
|
+
```javascript
|
|
462
|
+
import { createServer } from 'javascript-solid-server';
|
|
463
|
+
|
|
464
|
+
const server = createServer({
|
|
465
|
+
activitypub: true,
|
|
466
|
+
apUsername: 'alice',
|
|
467
|
+
apDisplayName: 'Alice',
|
|
468
|
+
apSummary: 'Building the decentralized web!',
|
|
469
|
+
apNostrPubkey: 'abc123...' // Optional: links to did:nostr
|
|
470
|
+
});
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
### Testing Federation
|
|
474
|
+
|
|
475
|
+
```bash
|
|
476
|
+
# Check WebFinger
|
|
477
|
+
curl "http://localhost:3000/.well-known/webfinger?resource=acct:alice@localhost:3000"
|
|
478
|
+
|
|
479
|
+
# Get Actor (AP format)
|
|
480
|
+
curl -H "Accept: application/activity+json" http://localhost:3000/profile/card
|
|
481
|
+
|
|
482
|
+
# Check NodeInfo
|
|
483
|
+
curl http://localhost:3000/.well-known/nodeinfo/2.1
|
|
484
|
+
```
|
|
485
|
+
|
|
412
486
|
### Linking Nostr to WebID (did:nostr)
|
|
413
487
|
|
|
414
488
|
Bridge your Nostr identity to a Solid WebID for seamless authentication:
|
|
@@ -812,6 +886,15 @@ src/
|
|
|
812
886
|
│ ├── interactions.js # Login/consent handlers
|
|
813
887
|
│ ├── views.js # HTML templates
|
|
814
888
|
│ └── invites.js # Invite code management
|
|
889
|
+
├── ap/
|
|
890
|
+
│ ├── index.js # ActivityPub plugin
|
|
891
|
+
│ ├── keys.js # RSA keypair management
|
|
892
|
+
│ ├── store.js # SQLite storage (followers, activities)
|
|
893
|
+
│ └── routes/
|
|
894
|
+
│ ├── actor.js # Actor JSON-LD
|
|
895
|
+
│ ├── inbox.js # Receive activities
|
|
896
|
+
│ ├── outbox.js # User's activities
|
|
897
|
+
│ └── collections.js # Followers/following
|
|
815
898
|
├── rdf/
|
|
816
899
|
│ ├── turtle.js # Turtle <-> JSON-LD
|
|
817
900
|
│ └── conneg.js # Content negotiation
|
|
@@ -831,6 +914,8 @@ Minimal dependencies for a fast, secure server:
|
|
|
831
914
|
- **n3** - Turtle parsing (only used when conneg enabled)
|
|
832
915
|
- **oidc-provider** - OpenID Connect Identity Provider (only when IdP enabled)
|
|
833
916
|
- **bcrypt** - Password hashing (only when IdP enabled)
|
|
917
|
+
- **microfed** - ActivityPub primitives (only when activitypub enabled)
|
|
918
|
+
- **better-sqlite3** - SQLite storage for federation data
|
|
834
919
|
|
|
835
920
|
## License
|
|
836
921
|
|
package/bin/jss.js
CHANGED
|
@@ -63,6 +63,12 @@ program
|
|
|
63
63
|
.option('--no-nostr', 'Disable Nostr relay')
|
|
64
64
|
.option('--nostr-path <path>', 'Nostr relay WebSocket path (default: /relay)')
|
|
65
65
|
.option('--nostr-max-events <n>', 'Max events in relay memory (default: 1000)', parseInt)
|
|
66
|
+
.option('--activitypub', 'Enable ActivityPub federation')
|
|
67
|
+
.option('--no-activitypub', 'Disable ActivityPub federation')
|
|
68
|
+
.option('--ap-username <name>', 'ActivityPub username (default: me)')
|
|
69
|
+
.option('--ap-display-name <name>', 'ActivityPub display name')
|
|
70
|
+
.option('--ap-summary <text>', 'ActivityPub bio/summary')
|
|
71
|
+
.option('--ap-nostr-pubkey <hex>', 'Nostr pubkey for identity linking')
|
|
66
72
|
.option('--invite-only', 'Require invite code for registration')
|
|
67
73
|
.option('--no-invite-only', 'Allow open registration')
|
|
68
74
|
.option('-q, --quiet', 'Suppress log output')
|
|
@@ -110,6 +116,11 @@ program
|
|
|
110
116
|
nostr: config.nostr,
|
|
111
117
|
nostrPath: config.nostrPath,
|
|
112
118
|
nostrMaxEvents: config.nostrMaxEvents,
|
|
119
|
+
activitypub: config.activitypub,
|
|
120
|
+
apUsername: config.apUsername,
|
|
121
|
+
apDisplayName: config.apDisplayName,
|
|
122
|
+
apSummary: config.apSummary,
|
|
123
|
+
apNostrPubkey: config.apNostrPubkey,
|
|
113
124
|
inviteOnly: config.inviteOnly,
|
|
114
125
|
});
|
|
115
126
|
|
|
@@ -131,6 +142,7 @@ program
|
|
|
131
142
|
}
|
|
132
143
|
if (config.git) console.log(' Git: enabled (clone/push support)');
|
|
133
144
|
if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
|
|
145
|
+
if (config.activitypub) console.log(` ActivityPub: enabled (@${config.apUsername || 'me'})`);
|
|
134
146
|
if (config.inviteOnly) console.log(' Registration: invite-only');
|
|
135
147
|
console.log('\n Press Ctrl+C to stop\n');
|
|
136
148
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javascript-solid-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.64",
|
|
4
4
|
"description": "A minimal, fast Solid server",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"microfed": "^0.0.14",
|
|
37
37
|
"n3": "^1.26.0",
|
|
38
38
|
"nostr-tools": "^2.19.4",
|
|
39
|
-
"oidc-provider": "^9.6.0"
|
|
39
|
+
"oidc-provider": "^9.6.0",
|
|
40
|
+
"sql.js": "^1.13.0"
|
|
40
41
|
},
|
|
41
42
|
"engines": {
|
|
42
43
|
"node": ">=18.0.0"
|
package/src/ap/index.js
CHANGED
|
@@ -11,6 +11,10 @@ import { createOutboxHandler } from './routes/outbox.js'
|
|
|
11
11
|
import { createCollectionsHandler } from './routes/collections.js'
|
|
12
12
|
import { createActorHandler } from './routes/actor.js'
|
|
13
13
|
|
|
14
|
+
// Shared state for actor handler (accessed by server.js)
|
|
15
|
+
let sharedActorHandler = null
|
|
16
|
+
export function getActorHandler() { return sharedActorHandler }
|
|
17
|
+
|
|
14
18
|
/**
|
|
15
19
|
* ActivityPub Fastify plugin
|
|
16
20
|
* @param {FastifyInstance} fastify
|
|
@@ -23,7 +27,7 @@ import { createActorHandler } from './routes/actor.js'
|
|
|
23
27
|
export async function activityPubPlugin(fastify, options = {}) {
|
|
24
28
|
// Initialize storage and keypair
|
|
25
29
|
const keypair = loadOrCreateKeypair()
|
|
26
|
-
initStore()
|
|
30
|
+
await initStore()
|
|
27
31
|
|
|
28
32
|
// Store config for handlers
|
|
29
33
|
const config = {
|
|
@@ -37,16 +41,37 @@ export async function activityPubPlugin(fastify, options = {}) {
|
|
|
37
41
|
// Decorate fastify with AP config
|
|
38
42
|
fastify.decorate('apConfig', config)
|
|
39
43
|
|
|
44
|
+
// Helper to detect protocol from proxy headers
|
|
45
|
+
const getProtocol = (request) => {
|
|
46
|
+
// Check X-Forwarded-Proto first
|
|
47
|
+
let protocol = request.headers['x-forwarded-proto']
|
|
48
|
+
if (!protocol) {
|
|
49
|
+
// Cloudflare uses cf-visitor: {"scheme":"https"}
|
|
50
|
+
const cfVisitor = request.headers['cf-visitor']
|
|
51
|
+
if (cfVisitor) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(cfVisitor)
|
|
54
|
+
protocol = parsed.scheme
|
|
55
|
+
} catch { /* ignore */ }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// If still no protocol and hostname looks like a public domain, assume https
|
|
59
|
+
if (!protocol && request.hostname && !request.hostname.match(/^(localhost|127\.|192\.168\.|10\.)/)) {
|
|
60
|
+
protocol = 'https'
|
|
61
|
+
}
|
|
62
|
+
return protocol || request.protocol
|
|
63
|
+
}
|
|
64
|
+
|
|
40
65
|
// Helper to build actor ID from request
|
|
41
66
|
const getActorId = (request) => {
|
|
42
|
-
const protocol = request
|
|
67
|
+
const protocol = getProtocol(request)
|
|
43
68
|
const host = request.headers['x-forwarded-host'] || request.hostname
|
|
44
69
|
return `${protocol}://${host}/profile/card#me`
|
|
45
70
|
}
|
|
46
71
|
|
|
47
72
|
// Helper to get base URL
|
|
48
73
|
const getBaseUrl = (request) => {
|
|
49
|
-
const protocol = request
|
|
74
|
+
const protocol = getProtocol(request)
|
|
50
75
|
const host = request.headers['x-forwarded-host'] || request.hostname
|
|
51
76
|
return `${protocol}://${host}`
|
|
52
77
|
}
|
|
@@ -110,7 +135,7 @@ export async function activityPubPlugin(fastify, options = {}) {
|
|
|
110
135
|
version: '2.1',
|
|
111
136
|
software: {
|
|
112
137
|
name: 'jss',
|
|
113
|
-
version: '0.0.
|
|
138
|
+
version: '0.0.62',
|
|
114
139
|
repository: 'https://github.com/JavaScriptSolidServer/JavaScriptSolidServer'
|
|
115
140
|
},
|
|
116
141
|
protocols: ['activitypub', 'solid'],
|
|
@@ -127,37 +152,11 @@ export async function activityPubPlugin(fastify, options = {}) {
|
|
|
127
152
|
})
|
|
128
153
|
})
|
|
129
154
|
|
|
130
|
-
// Actor endpoint -
|
|
155
|
+
// Actor endpoint - expose handler for profile/card AP requests
|
|
131
156
|
const actorHandler = createActorHandler(config, keypair)
|
|
132
157
|
|
|
133
|
-
//
|
|
134
|
-
|
|
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
|
-
})
|
|
158
|
+
// Store actorHandler in shared state for use by server-level hook
|
|
159
|
+
sharedActorHandler = actorHandler
|
|
161
160
|
|
|
162
161
|
// Inbox endpoint
|
|
163
162
|
const inboxHandler = createInboxHandler(config, keypair)
|
package/src/ap/routes/actor.js
CHANGED
|
@@ -11,8 +11,24 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export function createActorHandler(config, keypair) {
|
|
13
13
|
return (request) => {
|
|
14
|
-
|
|
14
|
+
// Check various proxy headers for protocol detection
|
|
15
|
+
let protocol = request.headers['x-forwarded-proto']
|
|
16
|
+
if (!protocol) {
|
|
17
|
+
// Cloudflare uses cf-visitor: {"scheme":"https"}
|
|
18
|
+
const cfVisitor = request.headers['cf-visitor']
|
|
19
|
+
if (cfVisitor) {
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(cfVisitor)
|
|
22
|
+
protocol = parsed.scheme
|
|
23
|
+
} catch { /* ignore */ }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// If still no protocol and hostname looks like a public domain, assume https
|
|
15
27
|
const host = request.headers['x-forwarded-host'] || request.hostname
|
|
28
|
+
if (!protocol && host && !host.match(/^(localhost|127\.|192\.168\.|10\.)/)) {
|
|
29
|
+
protocol = 'https'
|
|
30
|
+
}
|
|
31
|
+
protocol = protocol || request.protocol
|
|
16
32
|
const baseUrl = `${protocol}://${host}`
|
|
17
33
|
const profileUrl = `${baseUrl}/profile/card`
|
|
18
34
|
const actorId = `${profileUrl}#me`
|
package/src/ap/store.js
CHANGED
|
@@ -1,72 +1,117 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ActivityPub SQLite Storage
|
|
3
3
|
* Persistence layer for federation data
|
|
4
|
+
*
|
|
5
|
+
* Uses better-sqlite3 when available (native, fast)
|
|
6
|
+
* Falls back to sql.js on Android/platforms without native builds
|
|
4
7
|
*/
|
|
5
8
|
|
|
6
|
-
import
|
|
7
|
-
import { existsSync, mkdirSync } from 'fs'
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
8
10
|
import { dirname } from 'path'
|
|
9
11
|
|
|
10
12
|
let db = null
|
|
13
|
+
let dbPath = null
|
|
14
|
+
let usingSqlJs = false
|
|
15
|
+
|
|
16
|
+
// SQL schema
|
|
17
|
+
const SCHEMA = `
|
|
18
|
+
-- Followers (people following us)
|
|
19
|
+
CREATE TABLE IF NOT EXISTS followers (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
actor TEXT NOT NULL,
|
|
22
|
+
inbox TEXT,
|
|
23
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
-- Following (people we follow)
|
|
27
|
+
CREATE TABLE IF NOT EXISTS following (
|
|
28
|
+
id TEXT PRIMARY KEY,
|
|
29
|
+
actor TEXT NOT NULL,
|
|
30
|
+
accepted INTEGER DEFAULT 0,
|
|
31
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
-- Activities (inbox)
|
|
35
|
+
CREATE TABLE IF NOT EXISTS activities (
|
|
36
|
+
id TEXT PRIMARY KEY,
|
|
37
|
+
type TEXT NOT NULL,
|
|
38
|
+
actor TEXT,
|
|
39
|
+
object TEXT,
|
|
40
|
+
raw TEXT NOT NULL,
|
|
41
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
-- Posts (our outbox)
|
|
45
|
+
CREATE TABLE IF NOT EXISTS posts (
|
|
46
|
+
id TEXT PRIMARY KEY,
|
|
47
|
+
content TEXT NOT NULL,
|
|
48
|
+
in_reply_to TEXT,
|
|
49
|
+
published TEXT DEFAULT CURRENT_TIMESTAMP
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
-- Known actors (cache)
|
|
53
|
+
CREATE TABLE IF NOT EXISTS actors (
|
|
54
|
+
id TEXT PRIMARY KEY,
|
|
55
|
+
data TEXT NOT NULL,
|
|
56
|
+
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
57
|
+
);
|
|
58
|
+
`
|
|
11
59
|
|
|
12
60
|
/**
|
|
13
61
|
* Initialize the database
|
|
62
|
+
* Tries better-sqlite3 first, falls back to sql.js
|
|
14
63
|
* @param {string} path - Path to SQLite file
|
|
15
64
|
*/
|
|
16
|
-
export function initStore(path = 'data/activitypub.db') {
|
|
65
|
+
export async function initStore(path = 'data/activitypub.db') {
|
|
17
66
|
// Ensure directory exists
|
|
18
67
|
const dir = dirname(path)
|
|
19
68
|
if (!existsSync(dir)) {
|
|
20
69
|
mkdirSync(dir, { recursive: true })
|
|
21
70
|
}
|
|
22
71
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
`)
|
|
72
|
+
dbPath = path
|
|
73
|
+
|
|
74
|
+
// Try better-sqlite3 first (fast, native)
|
|
75
|
+
try {
|
|
76
|
+
const Database = (await import('better-sqlite3')).default
|
|
77
|
+
db = new Database(path)
|
|
78
|
+
db.exec(SCHEMA)
|
|
79
|
+
usingSqlJs = false
|
|
80
|
+
return db
|
|
81
|
+
} catch (e) {
|
|
82
|
+
// Fall back to sql.js (WASM, works everywhere)
|
|
83
|
+
console.log('ActivityPub: Using sql.js (WASM) for SQLite storage')
|
|
84
|
+
|
|
85
|
+
const initSqlJs = (await import('sql.js')).default
|
|
86
|
+
const SQL = await initSqlJs()
|
|
87
|
+
|
|
88
|
+
// Load existing database if it exists
|
|
89
|
+
if (existsSync(path)) {
|
|
90
|
+
const buffer = readFileSync(path)
|
|
91
|
+
db = new SQL.Database(buffer)
|
|
92
|
+
} else {
|
|
93
|
+
db = new SQL.Database()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
db.run(SCHEMA)
|
|
97
|
+
usingSqlJs = true
|
|
98
|
+
|
|
99
|
+
// Save initial database
|
|
100
|
+
saveDatabase()
|
|
101
|
+
|
|
102
|
+
return db
|
|
103
|
+
}
|
|
104
|
+
}
|
|
68
105
|
|
|
69
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Save sql.js database to disk
|
|
108
|
+
*/
|
|
109
|
+
function saveDatabase() {
|
|
110
|
+
if (usingSqlJs && db && dbPath) {
|
|
111
|
+
const data = db.export()
|
|
112
|
+
const buffer = Buffer.from(data)
|
|
113
|
+
writeFileSync(dbPath, buffer)
|
|
114
|
+
}
|
|
70
115
|
}
|
|
71
116
|
|
|
72
117
|
/**
|
|
@@ -79,128 +124,156 @@ export function getStore() {
|
|
|
79
124
|
return db
|
|
80
125
|
}
|
|
81
126
|
|
|
127
|
+
// Helper to run prepared statements across both implementations
|
|
128
|
+
function runStmt(sql, params = []) {
|
|
129
|
+
if (usingSqlJs) {
|
|
130
|
+
db.run(sql, params)
|
|
131
|
+
saveDatabase()
|
|
132
|
+
} else {
|
|
133
|
+
db.prepare(sql).run(...params)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getOne(sql, params = []) {
|
|
138
|
+
if (usingSqlJs) {
|
|
139
|
+
const stmt = db.prepare(sql)
|
|
140
|
+
stmt.bind(params)
|
|
141
|
+
if (stmt.step()) {
|
|
142
|
+
const row = stmt.getAsObject()
|
|
143
|
+
stmt.free()
|
|
144
|
+
return row
|
|
145
|
+
}
|
|
146
|
+
stmt.free()
|
|
147
|
+
return null
|
|
148
|
+
} else {
|
|
149
|
+
return db.prepare(sql).get(...params)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getAll(sql, params = []) {
|
|
154
|
+
if (usingSqlJs) {
|
|
155
|
+
const results = []
|
|
156
|
+
const stmt = db.prepare(sql)
|
|
157
|
+
stmt.bind(params)
|
|
158
|
+
while (stmt.step()) {
|
|
159
|
+
results.push(stmt.getAsObject())
|
|
160
|
+
}
|
|
161
|
+
stmt.free()
|
|
162
|
+
return results
|
|
163
|
+
} else {
|
|
164
|
+
return db.prepare(sql).all(...params)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
82
168
|
// Followers
|
|
83
169
|
|
|
84
170
|
export function addFollower(actorId, inbox) {
|
|
85
|
-
|
|
86
|
-
INSERT OR REPLACE INTO followers (id, actor, inbox)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
stmt.run(actorId, actorId, inbox)
|
|
171
|
+
runStmt(
|
|
172
|
+
'INSERT OR REPLACE INTO followers (id, actor, inbox) VALUES (?, ?, ?)',
|
|
173
|
+
[actorId, actorId, inbox]
|
|
174
|
+
)
|
|
90
175
|
}
|
|
91
176
|
|
|
92
177
|
export function removeFollower(actorId) {
|
|
93
|
-
|
|
94
|
-
stmt.run(actorId)
|
|
178
|
+
runStmt('DELETE FROM followers WHERE id = ?', [actorId])
|
|
95
179
|
}
|
|
96
180
|
|
|
97
181
|
export function getFollowers() {
|
|
98
|
-
|
|
99
|
-
return stmt.all()
|
|
182
|
+
return getAll('SELECT * FROM followers ORDER BY created_at DESC')
|
|
100
183
|
}
|
|
101
184
|
|
|
102
185
|
export function getFollowerCount() {
|
|
103
|
-
const
|
|
104
|
-
return
|
|
186
|
+
const row = getOne('SELECT COUNT(*) as count FROM followers')
|
|
187
|
+
return row ? row.count : 0
|
|
105
188
|
}
|
|
106
189
|
|
|
107
190
|
export function getFollowerInboxes() {
|
|
108
|
-
|
|
109
|
-
|
|
191
|
+
return getAll('SELECT DISTINCT inbox FROM followers WHERE inbox IS NOT NULL')
|
|
192
|
+
.map(row => row.inbox)
|
|
110
193
|
}
|
|
111
194
|
|
|
112
195
|
// Following
|
|
113
196
|
|
|
114
197
|
export function addFollowing(actorId, accepted = false) {
|
|
115
|
-
|
|
116
|
-
INSERT OR REPLACE INTO following (id, actor, accepted)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
stmt.run(actorId, actorId, accepted ? 1 : 0)
|
|
198
|
+
runStmt(
|
|
199
|
+
'INSERT OR REPLACE INTO following (id, actor, accepted) VALUES (?, ?, ?)',
|
|
200
|
+
[actorId, actorId, accepted ? 1 : 0]
|
|
201
|
+
)
|
|
120
202
|
}
|
|
121
203
|
|
|
122
204
|
export function acceptFollowing(actorId) {
|
|
123
|
-
|
|
124
|
-
stmt.run(actorId)
|
|
205
|
+
runStmt('UPDATE following SET accepted = 1 WHERE id = ?', [actorId])
|
|
125
206
|
}
|
|
126
207
|
|
|
127
208
|
export function removeFollowing(actorId) {
|
|
128
|
-
|
|
129
|
-
stmt.run(actorId)
|
|
209
|
+
runStmt('DELETE FROM following WHERE id = ?', [actorId])
|
|
130
210
|
}
|
|
131
211
|
|
|
132
212
|
export function getFollowing() {
|
|
133
|
-
|
|
134
|
-
return stmt.all()
|
|
213
|
+
return getAll('SELECT * FROM following WHERE accepted = 1 ORDER BY created_at DESC')
|
|
135
214
|
}
|
|
136
215
|
|
|
137
216
|
export function getFollowingCount() {
|
|
138
|
-
const
|
|
139
|
-
return
|
|
217
|
+
const row = getOne('SELECT COUNT(*) as count FROM following WHERE accepted = 1')
|
|
218
|
+
return row ? row.count : 0
|
|
140
219
|
}
|
|
141
220
|
|
|
142
221
|
// Activities
|
|
143
222
|
|
|
144
223
|
export function saveActivity(activity) {
|
|
145
|
-
|
|
146
|
-
INSERT OR REPLACE INTO activities (id, type, actor, object, raw)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
JSON.stringify(activity)
|
|
224
|
+
runStmt(
|
|
225
|
+
'INSERT OR REPLACE INTO activities (id, type, actor, object, raw) VALUES (?, ?, ?, ?, ?)',
|
|
226
|
+
[
|
|
227
|
+
activity.id,
|
|
228
|
+
activity.type,
|
|
229
|
+
typeof activity.actor === 'string' ? activity.actor : activity.actor?.id,
|
|
230
|
+
typeof activity.object === 'string' ? activity.object : JSON.stringify(activity.object),
|
|
231
|
+
JSON.stringify(activity)
|
|
232
|
+
]
|
|
155
233
|
)
|
|
156
234
|
}
|
|
157
235
|
|
|
158
236
|
export function getActivities(limit = 20) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
237
|
+
return getAll('SELECT * FROM activities ORDER BY created_at DESC LIMIT ?', [limit])
|
|
238
|
+
.map(row => ({
|
|
239
|
+
...row,
|
|
240
|
+
raw: JSON.parse(row.raw)
|
|
241
|
+
}))
|
|
164
242
|
}
|
|
165
243
|
|
|
166
244
|
// Posts
|
|
167
245
|
|
|
168
246
|
export function savePost(id, content, inReplyTo = null) {
|
|
169
|
-
|
|
170
|
-
INSERT INTO posts (id, content, in_reply_to)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
stmt.run(id, content, inReplyTo)
|
|
247
|
+
runStmt(
|
|
248
|
+
'INSERT INTO posts (id, content, in_reply_to) VALUES (?, ?, ?)',
|
|
249
|
+
[id, content, inReplyTo]
|
|
250
|
+
)
|
|
174
251
|
}
|
|
175
252
|
|
|
176
253
|
export function getPosts(limit = 20) {
|
|
177
|
-
|
|
178
|
-
return stmt.all(limit)
|
|
254
|
+
return getAll('SELECT * FROM posts ORDER BY published DESC LIMIT ?', [limit])
|
|
179
255
|
}
|
|
180
256
|
|
|
181
257
|
export function getPost(id) {
|
|
182
|
-
|
|
183
|
-
return stmt.get(id)
|
|
258
|
+
return getOne('SELECT * FROM posts WHERE id = ?', [id])
|
|
184
259
|
}
|
|
185
260
|
|
|
186
261
|
export function getPostCount() {
|
|
187
|
-
const
|
|
188
|
-
return
|
|
262
|
+
const row = getOne('SELECT COUNT(*) as count FROM posts')
|
|
263
|
+
return row ? row.count : 0
|
|
189
264
|
}
|
|
190
265
|
|
|
191
266
|
// Actor cache
|
|
192
267
|
|
|
193
268
|
export function cacheActor(actor) {
|
|
194
|
-
|
|
195
|
-
INSERT OR REPLACE INTO actors (id, data, fetched_at)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
stmt.run(actor.id, JSON.stringify(actor))
|
|
269
|
+
runStmt(
|
|
270
|
+
'INSERT OR REPLACE INTO actors (id, data, fetched_at) VALUES (?, ?, datetime("now"))',
|
|
271
|
+
[actor.id, JSON.stringify(actor)]
|
|
272
|
+
)
|
|
199
273
|
}
|
|
200
274
|
|
|
201
275
|
export function getCachedActor(id) {
|
|
202
|
-
const
|
|
203
|
-
const row = stmt.get(id)
|
|
276
|
+
const row = getOne('SELECT * FROM actors WHERE id = ?', [id])
|
|
204
277
|
return row ? JSON.parse(row.data) : null
|
|
205
278
|
}
|
|
206
279
|
|
package/src/config.js
CHANGED
|
@@ -50,6 +50,13 @@ export const defaults = {
|
|
|
50
50
|
nostrPath: '/relay',
|
|
51
51
|
nostrMaxEvents: 1000,
|
|
52
52
|
|
|
53
|
+
// ActivityPub federation
|
|
54
|
+
activitypub: false,
|
|
55
|
+
apUsername: 'me',
|
|
56
|
+
apDisplayName: null,
|
|
57
|
+
apSummary: null,
|
|
58
|
+
apNostrPubkey: null,
|
|
59
|
+
|
|
53
60
|
// Invite-only registration
|
|
54
61
|
inviteOnly: false,
|
|
55
62
|
|
|
@@ -89,6 +96,11 @@ const envMap = {
|
|
|
89
96
|
JSS_NOSTR: 'nostr',
|
|
90
97
|
JSS_NOSTR_PATH: 'nostrPath',
|
|
91
98
|
JSS_NOSTR_MAX_EVENTS: 'nostrMaxEvents',
|
|
99
|
+
JSS_ACTIVITYPUB: 'activitypub',
|
|
100
|
+
JSS_AP_USERNAME: 'apUsername',
|
|
101
|
+
JSS_AP_DISPLAY_NAME: 'apDisplayName',
|
|
102
|
+
JSS_AP_SUMMARY: 'apSummary',
|
|
103
|
+
JSS_AP_NOSTR_PUBKEY: 'apNostrPubkey',
|
|
92
104
|
JSS_INVITE_ONLY: 'inviteOnly',
|
|
93
105
|
JSS_DEFAULT_QUOTA: 'defaultQuota',
|
|
94
106
|
};
|
package/src/server.js
CHANGED
|
@@ -12,7 +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
|
+
import { activityPubPlugin, getActorHandler } from './ap/index.js';
|
|
16
16
|
|
|
17
17
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
18
|
|
|
@@ -202,6 +202,33 @@ export function createServer(options = {}) {
|
|
|
202
202
|
// Note: OPTIONS requests are handled by handleOptions to include Accept-* headers
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
+
// ActivityPub actor endpoint - dedicated route for /profile/card with AP Accept header
|
|
206
|
+
// Registered before wildcard routes to take priority
|
|
207
|
+
if (activitypubEnabled) {
|
|
208
|
+
fastify.route({
|
|
209
|
+
method: 'GET',
|
|
210
|
+
url: '/profile/card',
|
|
211
|
+
handler: async (request, reply) => {
|
|
212
|
+
const accept = request.headers.accept || '';
|
|
213
|
+
const wantsAP = accept.includes('activity+json') ||
|
|
214
|
+
accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"');
|
|
215
|
+
|
|
216
|
+
const actorHandler = getActorHandler();
|
|
217
|
+
if (wantsAP && actorHandler) {
|
|
218
|
+
const actor = actorHandler(request);
|
|
219
|
+
return reply
|
|
220
|
+
.type('application/activity+json')
|
|
221
|
+
.send(actor);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Not AP request - serve the HTML profile from disk
|
|
225
|
+
// This is handled by importing the resource handler
|
|
226
|
+
const { handleGet } = await import('./handlers/resource.js');
|
|
227
|
+
return handleGet(request, reply);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
205
232
|
// Security: Block access to dotfiles except allowed Solid-specific ones
|
|
206
233
|
// This prevents exposure of .git/, .env, .htpasswd, etc.
|
|
207
234
|
// Git protocol requests bypass this check when git is enabled
|