javascript-solid-server 0.0.94 → 0.0.95
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 +4 -2
- package/README.md +49 -0
- package/bin/jss.js +8 -0
- package/package.json +4 -1
- package/src/config.js +9 -0
- package/src/db/index.js +303 -0
- package/src/db/store.js +154 -0
- package/src/server.js +11 -0
|
@@ -141,7 +141,6 @@
|
|
|
141
141
|
"Bash(DATA_ROOT=/tmp/jss-git-test/data node:*)",
|
|
142
142
|
"Bash(git remote set-url:*)",
|
|
143
143
|
"Bash(for:*)",
|
|
144
|
-
"Bash(^/**\" | head -1 | sed ''''s/.*\\* //'''')\")",
|
|
145
144
|
"Bash(if [ ! -d \"node-solid-server\" ])",
|
|
146
145
|
"Bash(then git clone --depth 1 https://github.com/nodeSolidServer/node-solid-server.git)",
|
|
147
146
|
"Bash(node test-local-nss2.js:*)",
|
|
@@ -325,7 +324,10 @@
|
|
|
325
324
|
"Bash(npm link:*)",
|
|
326
325
|
"Bash(git push)",
|
|
327
326
|
"Bash(ulimit:*)",
|
|
328
|
-
"Bash(gh label:*)"
|
|
327
|
+
"Bash(gh label:*)",
|
|
328
|
+
"Bash(mongosh --eval \"db.runCommand\\({ ping: 1 }\\)\" 2>&1 | head -5)",
|
|
329
|
+
"Bash(which jss && jss --version 2>&1)",
|
|
330
|
+
"Bash(jss start --help 2>&1 | grep -i mongo)"
|
|
329
331
|
]
|
|
330
332
|
}
|
|
331
333
|
}
|
package/README.md
CHANGED
|
@@ -148,6 +148,9 @@ jss --help # Show help
|
|
|
148
148
|
| `--public` | Allow unauthenticated access (skip WAC) | false |
|
|
149
149
|
| `--read-only` | Disable PUT/DELETE/PATCH methods | false |
|
|
150
150
|
| `--live-reload` | Auto-refresh browser on file changes | false |
|
|
151
|
+
| `--mongo` | Enable MongoDB-backed /db/ route | false |
|
|
152
|
+
| `--mongo-url <url>` | MongoDB connection URL | mongodb://localhost:27017 |
|
|
153
|
+
| `--mongo-database <name>` | MongoDB database name | solid |
|
|
151
154
|
| `-q, --quiet` | Suppress logs | false |
|
|
152
155
|
|
|
153
156
|
### Environment Variables
|
|
@@ -173,6 +176,9 @@ export JSS_PUBLIC=true
|
|
|
173
176
|
export JSS_READ_ONLY=true
|
|
174
177
|
export JSS_LIVE_RELOAD=true
|
|
175
178
|
export JSS_SOLIDOS_UI=true
|
|
179
|
+
export JSS_MONGO=true
|
|
180
|
+
export JSS_MONGO_URL=mongodb://localhost:27017
|
|
181
|
+
export JSS_MONGO_DATABASE=solid
|
|
176
182
|
jss start
|
|
177
183
|
```
|
|
178
184
|
|
|
@@ -647,6 +653,49 @@ jss quota show alice
|
|
|
647
653
|
jss quota reconcile alice
|
|
648
654
|
```
|
|
649
655
|
|
|
656
|
+
## MongoDB Storage (`/db/` Route)
|
|
657
|
+
|
|
658
|
+
Optional MongoDB-backed route for JSON-LD documents that need scale (social feeds, posts, follows). All other routes continue using the filesystem unchanged.
|
|
659
|
+
|
|
660
|
+
```bash
|
|
661
|
+
# Install the optional MongoDB driver
|
|
662
|
+
npm install mongodb
|
|
663
|
+
|
|
664
|
+
# Start with MongoDB enabled
|
|
665
|
+
jss start --mongo --mongo-url mongodb://localhost:27017 --mongo-database solid
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
### Operations
|
|
669
|
+
|
|
670
|
+
```bash
|
|
671
|
+
# Store a document
|
|
672
|
+
curl -X PUT http://localhost:3000/db/alice/notes/1 \
|
|
673
|
+
-H "Content-Type: application/ld+json" \
|
|
674
|
+
-H "Authorization: Bearer <token>" \
|
|
675
|
+
-d '{"@context": "https://schema.org/", "@type": "Note", "text": "Hello"}'
|
|
676
|
+
|
|
677
|
+
# Read it back
|
|
678
|
+
curl http://localhost:3000/db/alice/notes/1
|
|
679
|
+
|
|
680
|
+
# List container (derived from URI prefixes)
|
|
681
|
+
curl http://localhost:3000/db/alice/
|
|
682
|
+
|
|
683
|
+
# Delete
|
|
684
|
+
curl -X DELETE http://localhost:3000/db/alice/notes/1 \
|
|
685
|
+
-H "Authorization: Bearer <token>"
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
### How It Works
|
|
689
|
+
|
|
690
|
+
- `GET /db/:path` — retrieve a document by URI, or list a virtual container
|
|
691
|
+
- `PUT /db/:path` — create or update (upsert) a JSON-LD document
|
|
692
|
+
- `DELETE /db/:path` — remove a document
|
|
693
|
+
- Returns standard LDP headers (Link, ETag, WAC-Allow, CORS)
|
|
694
|
+
- Supports conditional requests (If-Match, If-None-Match)
|
|
695
|
+
- Container listings are computed from URI prefix queries — no directory management needed
|
|
696
|
+
- Auth: pod owner can write (`/db/{podName}/...`), reads are public
|
|
697
|
+
- MongoDB is an optional dependency — the server runs without it
|
|
698
|
+
|
|
650
699
|
### How It Works
|
|
651
700
|
|
|
652
701
|
- Quotas are tracked incrementally on PUT, POST, and DELETE operations
|
package/bin/jss.js
CHANGED
|
@@ -80,6 +80,10 @@ program
|
|
|
80
80
|
.option('--public', 'Allow unauthenticated access (skip WAC, open read/write)')
|
|
81
81
|
.option('--read-only', 'Disable PUT/DELETE/PATCH methods (read-only mode)')
|
|
82
82
|
.option('--live-reload', 'Inject live reload script into HTML (auto-refresh on changes)')
|
|
83
|
+
.option('--mongo', 'Enable MongoDB-backed /db/ route')
|
|
84
|
+
.option('--no-mongo', 'Disable MongoDB-backed /db/ route')
|
|
85
|
+
.option('--mongo-url <url>', 'MongoDB connection URL (default: mongodb://localhost:27017)')
|
|
86
|
+
.option('--mongo-database <name>', 'MongoDB database name (default: solid)')
|
|
83
87
|
.option('-q, --quiet', 'Suppress log output')
|
|
84
88
|
.option('--log-level <level>', 'Log level: error, warn, info, debug (default: info)')
|
|
85
89
|
.option('--print-config', 'Print configuration and exit')
|
|
@@ -142,6 +146,9 @@ program
|
|
|
142
146
|
public: config.public,
|
|
143
147
|
readOnly: config.readOnly,
|
|
144
148
|
liveReload: config.liveReload,
|
|
149
|
+
mongo: config.mongo,
|
|
150
|
+
mongoUrl: config.mongoUrl,
|
|
151
|
+
mongoDatabase: config.mongoDatabase,
|
|
145
152
|
});
|
|
146
153
|
|
|
147
154
|
await server.listen({ port: config.port, host: config.host });
|
|
@@ -177,6 +184,7 @@ program
|
|
|
177
184
|
}
|
|
178
185
|
console.log(' Do not expose to the internet!');
|
|
179
186
|
}
|
|
187
|
+
if (config.mongo) console.log(` MongoDB: ${config.mongoUrl} (${config.mongoDatabase})`);
|
|
180
188
|
if (config.readOnly) console.log(' Read-only: enabled (PUT/DELETE/PATCH disabled)');
|
|
181
189
|
console.log('\n Press Ctrl+C to stop\n');
|
|
182
190
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javascript-solid-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.95",
|
|
4
4
|
"description": "A minimal, fast Solid server",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -51,6 +51,9 @@
|
|
|
51
51
|
"nostr"
|
|
52
52
|
],
|
|
53
53
|
"license": "AGPL-3.0-only",
|
|
54
|
+
"optionalDependencies": {
|
|
55
|
+
"mongodb": "^6.21.0"
|
|
56
|
+
},
|
|
54
57
|
"devDependencies": {
|
|
55
58
|
"autocannon": "^8.0.0"
|
|
56
59
|
}
|
package/src/config.js
CHANGED
|
@@ -83,6 +83,11 @@ export const defaults = {
|
|
|
83
83
|
// Live reload - inject script to auto-refresh browser on file changes
|
|
84
84
|
liveReload: false,
|
|
85
85
|
|
|
86
|
+
// MongoDB-backed /db/ route
|
|
87
|
+
mongo: false,
|
|
88
|
+
mongoUrl: 'mongodb://localhost:27017',
|
|
89
|
+
mongoDatabase: 'solid',
|
|
90
|
+
|
|
86
91
|
// Logging
|
|
87
92
|
logger: true,
|
|
88
93
|
quiet: false,
|
|
@@ -133,6 +138,9 @@ const envMap = {
|
|
|
133
138
|
JSS_PUBLIC: 'public',
|
|
134
139
|
JSS_READ_ONLY: 'readOnly',
|
|
135
140
|
JSS_LIVE_RELOAD: 'liveReload',
|
|
141
|
+
JSS_MONGO: 'mongo',
|
|
142
|
+
JSS_MONGO_URL: 'mongoUrl',
|
|
143
|
+
JSS_MONGO_DATABASE: 'mongoDatabase',
|
|
136
144
|
};
|
|
137
145
|
|
|
138
146
|
/**
|
|
@@ -297,5 +305,6 @@ export function printConfig(config) {
|
|
|
297
305
|
console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`);
|
|
298
306
|
console.log(` Mashlib: ${config.mashlibModule ? `module (${config.mashlibModule})` : config.mashlibCdn ? `CDN v${config.mashlibVersion}` : config.mashlib ? 'local' : 'disabled'}`);
|
|
299
307
|
console.log(` SolidOS UI: ${config.solidosUi ? 'enabled' : 'disabled'}`);
|
|
308
|
+
if (config.mongo) console.log(` MongoDB: ${config.mongoUrl} (${config.mongoDatabase})`);
|
|
300
309
|
console.log('─'.repeat(40));
|
|
301
310
|
}
|
package/src/db/index.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MongoDB Database Route Plugin for JSS
|
|
3
|
+
*
|
|
4
|
+
* Adds /db/* routes backed by MongoDB.
|
|
5
|
+
* Documents are stored as JSON-LD and keyed by URI.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { connect, disconnect, findOne, upsertOne, deleteOne, listByPrefix } from './store.js';
|
|
9
|
+
import { getAllHeaders, getNotFoundHeaders } from '../ldp/headers.js';
|
|
10
|
+
import { generateContainerJsonLd, serializeJsonLd } from '../ldp/container.js';
|
|
11
|
+
import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
|
|
12
|
+
import { emitChange } from '../notifications/events.js';
|
|
13
|
+
import { getWebIdFromRequestAsync } from '../auth/token.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Database route Fastify plugin
|
|
17
|
+
* @param {FastifyInstance} fastify
|
|
18
|
+
* @param {object} options
|
|
19
|
+
*/
|
|
20
|
+
export async function dbPlugin(fastify, options) {
|
|
21
|
+
await connect({
|
|
22
|
+
url: options.mongoUrl,
|
|
23
|
+
database: options.mongoDatabase || 'solid'
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
fastify.addHook('onClose', async () => {
|
|
27
|
+
await disconnect();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Auth hook for /db/* routes
|
|
31
|
+
// WAC doesn't apply here — uses WebID-based ownership
|
|
32
|
+
fastify.addHook('preHandler', async (request, reply) => {
|
|
33
|
+
if (request.method === 'OPTIONS') return;
|
|
34
|
+
|
|
35
|
+
// Public mode — skip auth
|
|
36
|
+
if (request.config?.public) {
|
|
37
|
+
request.webId = null;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { webId } = await getWebIdFromRequestAsync(request);
|
|
42
|
+
request.webId = webId;
|
|
43
|
+
|
|
44
|
+
// Read is public
|
|
45
|
+
if (request.method === 'GET' || request.method === 'HEAD') return;
|
|
46
|
+
|
|
47
|
+
// Write requires authentication
|
|
48
|
+
if (!webId) {
|
|
49
|
+
return reply.code(401).send({ error: 'Unauthorized', message: 'Authentication required' });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Ownership check: only pod owner can write to /db/{podName}/...
|
|
53
|
+
const urlPath = request.url.split('?')[0];
|
|
54
|
+
const relative = urlPath.replace(/^\/db\//, '');
|
|
55
|
+
const podName = relative.split('/')[0];
|
|
56
|
+
if (podName) {
|
|
57
|
+
// Build expected WebID for both path and subdomain modes
|
|
58
|
+
const expectedWebId = request.subdomainsEnabled && request.baseDomain
|
|
59
|
+
? `${request.protocol}://${podName}.${request.baseDomain}/profile/card#me`
|
|
60
|
+
: `${request.protocol}://${request.hostname}/${podName}/profile/card#me`;
|
|
61
|
+
if (webId !== expectedWebId) {
|
|
62
|
+
return reply.code(403).send({ error: 'Forbidden', message: 'You can only write to your own /db/ space' });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Routes
|
|
68
|
+
fastify.get('/db', handleDbGet);
|
|
69
|
+
fastify.get('/db/*', handleDbGet);
|
|
70
|
+
fastify.head('/db', handleDbHead);
|
|
71
|
+
fastify.head('/db/*', handleDbHead);
|
|
72
|
+
fastify.put('/db/*', handleDbPut);
|
|
73
|
+
fastify.delete('/db/*', handleDbDelete);
|
|
74
|
+
fastify.options('/db', handleDbOptions);
|
|
75
|
+
fastify.options('/db/*', handleDbOptions);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build the full resource URL for a /db/ request
|
|
80
|
+
*/
|
|
81
|
+
function getResourceUrl(request) {
|
|
82
|
+
const urlPath = request.url.split('?')[0];
|
|
83
|
+
return `${request.protocol}://${request.hostname}${urlPath}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* GET /db/* — read resource or container listing
|
|
88
|
+
*/
|
|
89
|
+
async function handleDbGet(request, reply) {
|
|
90
|
+
const urlPath = request.url.split('?')[0];
|
|
91
|
+
const resourceUrl = getResourceUrl(request);
|
|
92
|
+
const origin = request.headers.origin;
|
|
93
|
+
const connegEnabled = request.connegEnabled || false;
|
|
94
|
+
|
|
95
|
+
// Container request (treat /db as root container)
|
|
96
|
+
if (urlPath === '/db' || urlPath.endsWith('/')) {
|
|
97
|
+
const entries = await listByPrefix(resourceUrl);
|
|
98
|
+
const jsonLd = generateContainerJsonLd(resourceUrl, entries);
|
|
99
|
+
const content = serializeJsonLd(jsonLd);
|
|
100
|
+
|
|
101
|
+
const etag = `"container-${entries.length}"`;
|
|
102
|
+
|
|
103
|
+
const ifNoneMatch = request.headers['if-none-match'];
|
|
104
|
+
if (ifNoneMatch) {
|
|
105
|
+
const check = checkIfNoneMatchForGet(ifNoneMatch, etag);
|
|
106
|
+
if (!check.ok && check.notModified) {
|
|
107
|
+
return reply.code(304).send();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const headers = getAllHeaders({
|
|
112
|
+
isContainer: true, etag,
|
|
113
|
+
contentType: 'application/ld+json',
|
|
114
|
+
origin, resourceUrl, connegEnabled
|
|
115
|
+
});
|
|
116
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
117
|
+
return reply.send(content);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Resource request
|
|
121
|
+
const doc = await findOne(resourceUrl);
|
|
122
|
+
if (!doc) {
|
|
123
|
+
const headers = getNotFoundHeaders({ resourceUrl, origin, connegEnabled });
|
|
124
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
125
|
+
return reply.code(404).send({ error: 'Not Found' });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const ifNoneMatch = request.headers['if-none-match'];
|
|
129
|
+
if (ifNoneMatch) {
|
|
130
|
+
const check = checkIfNoneMatchForGet(ifNoneMatch, doc.etag);
|
|
131
|
+
if (!check.ok && check.notModified) {
|
|
132
|
+
return reply.code(304).send();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const headers = getAllHeaders({
|
|
137
|
+
isContainer: false, etag: doc.etag,
|
|
138
|
+
contentType: doc.contentType,
|
|
139
|
+
origin, resourceUrl, connegEnabled
|
|
140
|
+
});
|
|
141
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
142
|
+
return reply.send(JSON.stringify(doc.data, null, 2));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* HEAD /db/* — same as GET but no body
|
|
147
|
+
*/
|
|
148
|
+
async function handleDbHead(request, reply) {
|
|
149
|
+
const urlPath = request.url.split('?')[0];
|
|
150
|
+
const resourceUrl = getResourceUrl(request);
|
|
151
|
+
const origin = request.headers.origin;
|
|
152
|
+
const connegEnabled = request.connegEnabled || false;
|
|
153
|
+
|
|
154
|
+
if (urlPath === '/db' || urlPath.endsWith('/')) {
|
|
155
|
+
const entries = await listByPrefix(resourceUrl);
|
|
156
|
+
const etag = `"container-${entries.length}"`;
|
|
157
|
+
const headers = getAllHeaders({
|
|
158
|
+
isContainer: true, etag,
|
|
159
|
+
contentType: 'application/ld+json',
|
|
160
|
+
origin, resourceUrl, connegEnabled
|
|
161
|
+
});
|
|
162
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
163
|
+
return reply.code(200).send();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const doc = await findOne(resourceUrl);
|
|
167
|
+
if (!doc) {
|
|
168
|
+
const headers = getNotFoundHeaders({ resourceUrl, origin, connegEnabled });
|
|
169
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
170
|
+
return reply.code(404).send();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const headers = getAllHeaders({
|
|
174
|
+
isContainer: false, etag: doc.etag,
|
|
175
|
+
contentType: doc.contentType,
|
|
176
|
+
origin, resourceUrl, connegEnabled
|
|
177
|
+
});
|
|
178
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
179
|
+
return reply.code(200).send();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* PUT /db/* — create or update resource
|
|
184
|
+
*/
|
|
185
|
+
async function handleDbPut(request, reply) {
|
|
186
|
+
if (request.config?.readOnly) {
|
|
187
|
+
return reply.code(405).send({ error: 'Method Not Allowed', message: 'Server is in read-only mode' });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const urlPath = request.url.split('?')[0];
|
|
191
|
+
const resourceUrl = getResourceUrl(request);
|
|
192
|
+
|
|
193
|
+
if (urlPath.endsWith('/')) {
|
|
194
|
+
return reply.code(409).send({ error: 'Conflict', message: 'Cannot PUT to a container' });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Only accept JSON content types — stored as JSON-LD
|
|
198
|
+
const incomingType = (request.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
|
199
|
+
if (incomingType && incomingType !== 'application/ld+json' && incomingType !== 'application/json') {
|
|
200
|
+
return reply.code(415).send({ error: 'Unsupported Media Type', message: 'Only application/ld+json and application/json are accepted' });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Parse body
|
|
204
|
+
let data;
|
|
205
|
+
let body = request.body;
|
|
206
|
+
if (Buffer.isBuffer(body)) body = body.toString();
|
|
207
|
+
if (typeof body === 'string') {
|
|
208
|
+
try { data = JSON.parse(body); }
|
|
209
|
+
catch { return reply.code(400).send({ error: 'Bad Request', message: 'Invalid JSON' }); }
|
|
210
|
+
} else if (typeof body === 'object' && body !== null) {
|
|
211
|
+
data = body;
|
|
212
|
+
} else {
|
|
213
|
+
return reply.code(400).send({ error: 'Bad Request', message: 'Request body required' });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Conditional headers
|
|
217
|
+
const existing = await findOne(resourceUrl);
|
|
218
|
+
const currentEtag = existing?.etag || null;
|
|
219
|
+
|
|
220
|
+
const ifMatch = request.headers['if-match'];
|
|
221
|
+
if (ifMatch) {
|
|
222
|
+
const check = checkIfMatch(ifMatch, currentEtag);
|
|
223
|
+
if (!check.ok) return reply.code(check.status).send({ error: check.error });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const ifNoneMatch = request.headers['if-none-match'];
|
|
227
|
+
if (ifNoneMatch) {
|
|
228
|
+
const check = checkIfNoneMatchForWrite(ifNoneMatch, currentEtag);
|
|
229
|
+
if (!check.ok) return reply.code(check.status).send({ error: check.error });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const { created, etag } = await upsertOne(resourceUrl, data, 'application/ld+json');
|
|
233
|
+
|
|
234
|
+
const origin = request.headers.origin;
|
|
235
|
+
const headers = getAllHeaders({
|
|
236
|
+
isContainer: false, etag,
|
|
237
|
+
origin, resourceUrl,
|
|
238
|
+
connegEnabled: request.connegEnabled || false
|
|
239
|
+
});
|
|
240
|
+
headers['Location'] = resourceUrl;
|
|
241
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
242
|
+
|
|
243
|
+
if (request.notificationsEnabled) {
|
|
244
|
+
emitChange(resourceUrl);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return reply.code(created ? 201 : 204).send();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* DELETE /db/* — delete resource
|
|
252
|
+
*/
|
|
253
|
+
async function handleDbDelete(request, reply) {
|
|
254
|
+
if (request.config?.readOnly) {
|
|
255
|
+
return reply.code(405).send({ error: 'Method Not Allowed', message: 'Server is in read-only mode' });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const resourceUrl = getResourceUrl(request);
|
|
259
|
+
const origin = request.headers.origin;
|
|
260
|
+
|
|
261
|
+
const existing = await findOne(resourceUrl);
|
|
262
|
+
if (!existing) {
|
|
263
|
+
const headers = getNotFoundHeaders({ resourceUrl, origin, connegEnabled: request.connegEnabled || false });
|
|
264
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
265
|
+
return reply.code(404).send({ error: 'Not Found' });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const ifMatch = request.headers['if-match'];
|
|
269
|
+
if (ifMatch) {
|
|
270
|
+
const check = checkIfMatch(ifMatch, existing.etag);
|
|
271
|
+
if (!check.ok) return reply.code(check.status).send({ error: check.error });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
await deleteOne(resourceUrl);
|
|
275
|
+
|
|
276
|
+
const headers = getAllHeaders({
|
|
277
|
+
isContainer: false, origin, resourceUrl
|
|
278
|
+
});
|
|
279
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
280
|
+
|
|
281
|
+
if (request.notificationsEnabled) {
|
|
282
|
+
emitChange(resourceUrl);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return reply.code(204).send();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* OPTIONS /db/* — return allowed methods
|
|
290
|
+
*/
|
|
291
|
+
async function handleDbOptions(request, reply) {
|
|
292
|
+
const resourceUrl = getResourceUrl(request);
|
|
293
|
+
const origin = request.headers.origin;
|
|
294
|
+
const headers = getAllHeaders({
|
|
295
|
+
isContainer: request.url.split('?')[0] === '/db' || request.url.split('?')[0].endsWith('/'),
|
|
296
|
+
origin, resourceUrl,
|
|
297
|
+
connegEnabled: request.connegEnabled || false
|
|
298
|
+
});
|
|
299
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
300
|
+
return reply.code(204).send();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export default dbPlugin;
|
package/src/db/store.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MongoDB Storage Layer for /db/ route
|
|
3
|
+
*
|
|
4
|
+
* Optional dependency — dynamically imports 'mongodb'.
|
|
5
|
+
* Provides document-level CRUD keyed by URI.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
|
|
10
|
+
let client = null;
|
|
11
|
+
let db = null;
|
|
12
|
+
let col = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Connect to MongoDB
|
|
16
|
+
* @param {object} options
|
|
17
|
+
* @param {string} options.url - MongoDB connection URL
|
|
18
|
+
* @param {string} options.database - Database name
|
|
19
|
+
* @returns {Promise<void>}
|
|
20
|
+
*/
|
|
21
|
+
export async function connect({ url, database }) {
|
|
22
|
+
let MongoClient;
|
|
23
|
+
try {
|
|
24
|
+
({ MongoClient } = await import('mongodb'));
|
|
25
|
+
} catch {
|
|
26
|
+
throw new Error(
|
|
27
|
+
'MongoDB driver not installed. Install it with: npm install mongodb\n' +
|
|
28
|
+
'The mongodb package is optional and only needed when using --mongo.'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
client = new MongoClient(url);
|
|
33
|
+
await client.connect();
|
|
34
|
+
db = client.db(database);
|
|
35
|
+
col = db.collection('resources');
|
|
36
|
+
|
|
37
|
+
// Create unique index on URI for fast lookups
|
|
38
|
+
await col.createIndex({ uri: 1 }, { unique: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Disconnect from MongoDB
|
|
43
|
+
* @returns {Promise<void>}
|
|
44
|
+
*/
|
|
45
|
+
export async function disconnect() {
|
|
46
|
+
if (client) {
|
|
47
|
+
await client.close();
|
|
48
|
+
client = null;
|
|
49
|
+
db = null;
|
|
50
|
+
col = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Generate ETag from data
|
|
56
|
+
*/
|
|
57
|
+
function generateEtag(data) {
|
|
58
|
+
const hash = crypto.createHash('md5').update(JSON.stringify(data)).digest('hex');
|
|
59
|
+
return `"${hash}"`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Find a single document by URI
|
|
64
|
+
* @param {string} uri - The resource URI
|
|
65
|
+
* @returns {Promise<{data: object, contentType: string, etag: string, modified: Date} | null>}
|
|
66
|
+
*/
|
|
67
|
+
export async function findOne(uri) {
|
|
68
|
+
const doc = await col.findOne({ uri });
|
|
69
|
+
if (!doc) return null;
|
|
70
|
+
return {
|
|
71
|
+
data: doc.data,
|
|
72
|
+
contentType: doc.contentType,
|
|
73
|
+
etag: doc.etag,
|
|
74
|
+
modified: doc.modified
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Upsert a document by URI
|
|
80
|
+
* @param {string} uri - The resource URI
|
|
81
|
+
* @param {object} data - The document data (JSON-LD)
|
|
82
|
+
* @param {string} contentType - The content type
|
|
83
|
+
* @returns {Promise<{created: boolean, etag: string}>}
|
|
84
|
+
*/
|
|
85
|
+
export async function upsertOne(uri, data, contentType) {
|
|
86
|
+
const etag = generateEtag(data);
|
|
87
|
+
const now = new Date();
|
|
88
|
+
|
|
89
|
+
const result = await col.updateOne(
|
|
90
|
+
{ uri },
|
|
91
|
+
{
|
|
92
|
+
$set: { data, contentType, etag, modified: now },
|
|
93
|
+
$setOnInsert: { created: now }
|
|
94
|
+
},
|
|
95
|
+
{ upsert: true }
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
created: result.upsertedCount > 0,
|
|
100
|
+
etag
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Delete a document by URI
|
|
106
|
+
* @param {string} uri - The resource URI
|
|
107
|
+
* @returns {Promise<boolean>} - true if deleted, false if not found
|
|
108
|
+
*/
|
|
109
|
+
export async function deleteOne(uri) {
|
|
110
|
+
const result = await col.deleteOne({ uri });
|
|
111
|
+
return result.deletedCount > 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Escape special regex characters in a string
|
|
116
|
+
*/
|
|
117
|
+
function escapeRegex(str) {
|
|
118
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* List immediate children whose URI starts with a prefix (for container listings)
|
|
123
|
+
* @param {string} prefix - URI prefix (must end with '/')
|
|
124
|
+
* @returns {Promise<Array<{name: string, isDirectory: boolean}>>}
|
|
125
|
+
*/
|
|
126
|
+
export async function listByPrefix(prefix) {
|
|
127
|
+
const regex = new RegExp('^' + escapeRegex(prefix));
|
|
128
|
+
const docs = await col.find({ uri: regex }, { projection: { uri: 1 } }).toArray();
|
|
129
|
+
|
|
130
|
+
const children = new Map();
|
|
131
|
+
for (const doc of docs) {
|
|
132
|
+
const remainder = doc.uri.slice(prefix.length);
|
|
133
|
+
if (!remainder) continue;
|
|
134
|
+
const slashIndex = remainder.indexOf('/');
|
|
135
|
+
if (slashIndex === -1) {
|
|
136
|
+
// Direct child resource
|
|
137
|
+
children.set(remainder, false);
|
|
138
|
+
} else {
|
|
139
|
+
// Nested under a sub-container
|
|
140
|
+
const containerName = remainder.slice(0, slashIndex);
|
|
141
|
+
children.set(containerName, true);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return Array.from(children.entries()).map(([name, isDirectory]) => ({ name, isDirectory }));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Check if MongoDB is connected
|
|
150
|
+
* @returns {boolean}
|
|
151
|
+
*/
|
|
152
|
+
export function isConnected() {
|
|
153
|
+
return client !== null;
|
|
154
|
+
}
|
package/src/server.js
CHANGED
|
@@ -15,6 +15,7 @@ import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js'
|
|
|
15
15
|
import { AccessMode } from './wac/parser.js';
|
|
16
16
|
import { registerNostrRelay } from './nostr/relay.js';
|
|
17
17
|
import { activityPubPlugin, getActorHandler } from './ap/index.js';
|
|
18
|
+
import { dbPlugin } from './db/index.js';
|
|
18
19
|
|
|
19
20
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
21
|
|
|
@@ -84,6 +85,10 @@ export function createServer(options = {}) {
|
|
|
84
85
|
const webidTlsEnabled = options.webidTls ?? false;
|
|
85
86
|
// Live reload - injects script to auto-refresh browser on file changes
|
|
86
87
|
const liveReloadEnabled = options.liveReload ?? false;
|
|
88
|
+
// MongoDB-backed /db/ route is OFF by default
|
|
89
|
+
const mongoEnabled = options.mongo ?? false;
|
|
90
|
+
const mongoUrl = options.mongoUrl ?? 'mongodb://localhost:27017';
|
|
91
|
+
const mongoDatabase = options.mongoDatabase ?? 'solid';
|
|
87
92
|
|
|
88
93
|
// Set data root via environment variable if provided
|
|
89
94
|
if (options.root) {
|
|
@@ -229,6 +234,11 @@ export function createServer(options = {}) {
|
|
|
229
234
|
});
|
|
230
235
|
}
|
|
231
236
|
|
|
237
|
+
// Register MongoDB /db/ route if enabled
|
|
238
|
+
if (mongoEnabled) {
|
|
239
|
+
fastify.register(dbPlugin, { mongoUrl, mongoDatabase });
|
|
240
|
+
}
|
|
241
|
+
|
|
232
242
|
// Register rate limiting plugin
|
|
233
243
|
// Protects against brute force attacks and resource exhaustion
|
|
234
244
|
fastify.register(rateLimit, {
|
|
@@ -358,6 +368,7 @@ export function createServer(options = {}) {
|
|
|
358
368
|
(gitEnabled && isGitRequest(request.url)) ||
|
|
359
369
|
(activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
|
|
360
370
|
isProfileAP ||
|
|
371
|
+
(mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
|
|
361
372
|
mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
|
|
362
373
|
return;
|
|
363
374
|
}
|