@factoidal/core 0.20.0 → 0.22.0
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/CHANGELOG.md +82 -0
- package/README.md +38 -2
- package/authz/server.mjs +23 -2
- package/bin/engine.mjs +22 -1
- package/bin/factoidal.mjs +710 -38
- package/bin/pack-host.mjs +5 -0
- package/bin/serve.mjs +19 -3
- package/browser.d.ts +14 -0
- package/browser.js +16 -0
- package/index.d.ts +325 -9
- package/index.mjs +19 -0
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +6 -6
- package/lib/api.mjs +303 -12
- package/lib/browser-engine.mjs +28 -1
- package/lib/cid-node.mjs +77 -0
- package/lib/cid.mjs +146 -0
- package/lib/cipher-host.mjs +126 -0
- package/lib/engine-integrity.mjs +56 -0
- package/lib/errors.mjs +6 -0
- package/lib/nodes.mjs +234 -11
- package/lib/sbm11.mjs +14 -1
- package/lib/version.mjs +1 -1
- package/lws/README.md +9 -0
- package/lws/client.mjs +19 -2
- package/lws/persist.mjs +169 -0
- package/lws/server.mjs +82 -29
- package/package.json +2 -1
- package/solid/server/index.mjs +79 -13
- package/webdav/server.mjs +154 -25
- package/xmpp/client.mjs +332 -0
- package/xmpp/proxy.mjs +114 -0
package/lws/server.mjs
CHANGED
|
@@ -84,11 +84,11 @@
|
|
|
84
84
|
// produced and this code keeps working unchanged.
|
|
85
85
|
|
|
86
86
|
import { loadEngine } from '../bin/engine.mjs'
|
|
87
|
-
import { readSnapshot, persistIfChanged, storageKeyText, storageModeText, loadFiles, persistFilesIfChanged, readStepBodies } from './persist.mjs'
|
|
87
|
+
import { readSnapshot, persistIfChanged, storageKeyText, storageModeText, loadFiles, persistFilesIfChanged, readStepBodies, acquireDirLock, atRestNotice } from './persist.mjs'
|
|
88
88
|
|
|
89
89
|
/** The ops this host needs from the engine. */
|
|
90
90
|
export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFetched', 'lwsDelivered',
|
|
91
|
-
'lwsLoad', 'lwsPersist', 'lwsRead']
|
|
91
|
+
'lwsLoad', 'lwsPersist', 'lwsRead', 'lwsDirLock', 'lwsAtRestNotice']
|
|
92
92
|
|
|
93
93
|
/** How long this host waits for the answer to one delivery request before
|
|
94
94
|
* it reports "no answer" (status 0) to the engine. */
|
|
@@ -254,8 +254,11 @@ function contentBytes (request) {
|
|
|
254
254
|
* file) or "files" (one file per body and a journal, issue 918); passed unread
|
|
255
255
|
* @param {boolean|string[]} [options.cors] CORS (issue 923): false for none, a list of
|
|
256
256
|
* origins to answer; default: echo any valid request Origin. Passed unread
|
|
257
|
+
* @param {string} [options.lockCommand] the command named in the lock file of DIR
|
|
258
|
+
* (default "lws-serve"; https://github.com/danbri/factoidal/issues/1135)
|
|
257
259
|
* @param {object} [options.engine] an engine handle to reuse
|
|
258
|
-
* @returns {Promise<{step: Function, close: Function, handle: string, engine: object
|
|
260
|
+
* @returns {Promise<{step: Function, close: Function, handle: string, engine: object,
|
|
261
|
+
* lockFile: string|null, notices: string[]}>}
|
|
259
262
|
*/
|
|
260
263
|
export async function openLwsStorage (options = {}) {
|
|
261
264
|
const engine = options.engine !== undefined ? options.engine : await loadEngine()
|
|
@@ -315,8 +318,14 @@ export async function openLwsStorage (options = {}) {
|
|
|
315
318
|
if (typeof options.controllerInbox === 'string') config.controllerInbox = options.controllerInbox
|
|
316
319
|
// CORS (https://github.com/danbri/factoidal/issues/923): `cors` is false or
|
|
317
320
|
// a list of origins, passed unread; absent, the engine echoes the request
|
|
318
|
-
// Origin
|
|
321
|
+
// Origin under authorization and sends no CORS field with insecureNoAuth
|
|
322
|
+
// (issue 1124). L4Factoidal/LWS/Cors.lean decides every CORS field.
|
|
319
323
|
if (options.cors !== undefined) config.cors = options.cors
|
|
324
|
+
// The Host and Origin guard of a storage with insecureNoAuth
|
|
325
|
+
// (https://github.com/danbri/factoidal/issues/1124): extra host names and
|
|
326
|
+
// origins, passed unread; L4Factoidal/HTTP/LocalGuard.lean decides.
|
|
327
|
+
if (Array.isArray(options.allowHosts)) config.allowHosts = options.allowHosts
|
|
328
|
+
if (Array.isArray(options.allowOrigins)) config.allowOrigins = options.allowOrigins
|
|
320
329
|
// What this host bound: data for the engine's inbox check (issue 915).
|
|
321
330
|
if (options.listen !== undefined && options.listen !== null) config.listen = options.listen
|
|
322
331
|
config.entropy = entropyBase64()
|
|
@@ -335,36 +344,69 @@ export async function openLwsStorage (options = {}) {
|
|
|
335
344
|
const dir = typeof options.dir === 'string' ? options.dir : null
|
|
336
345
|
const storage = dir !== null ? storageModeText(options.storage) : options.storage
|
|
337
346
|
if (typeof storage === 'string') config.storage = storage
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
347
|
+
// One writing process per storage directory
|
|
348
|
+
// (https://github.com/danbri/factoidal/issues/1135): the lock is taken
|
|
349
|
+
// before anything in DIR is read, and released by `close` and at process
|
|
350
|
+
// exit. The engine decides the file, its contents and every refusal
|
|
351
|
+
// (L4Factoidal/LWS/DirLock.lean); a refusal is thrown with its message.
|
|
352
|
+
let lock = null
|
|
353
|
+
if (dir !== null) {
|
|
354
|
+
try {
|
|
355
|
+
lock = acquireDirLock(engine, dir, typeof options.lockCommand === 'string' ? options.lockCommand : 'lws-serve')
|
|
356
|
+
} catch (error) {
|
|
357
|
+
if (isUnknownOp(error)) {
|
|
358
|
+
throw new LwsHostError(
|
|
359
|
+
'the loaded l4factoidal WebAssembly module does not carry lwsDirLock',
|
|
360
|
+
{ unknownOp: true })
|
|
361
|
+
}
|
|
362
|
+
throw error
|
|
363
|
+
}
|
|
343
364
|
}
|
|
344
365
|
let opened
|
|
366
|
+
let handle
|
|
367
|
+
let filesMode
|
|
368
|
+
let notices = []
|
|
345
369
|
try {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
{ unknownOp: true })
|
|
370
|
+
// `dir` names the storage directory. The snapshot found there, if any,
|
|
371
|
+
// is the storage to continue from; the engine checks it.
|
|
372
|
+
if (dir !== null && storage !== 'files') {
|
|
373
|
+
const snapshot = readSnapshot(dir)
|
|
374
|
+
if (snapshot !== null) config.snapshot = snapshot
|
|
352
375
|
}
|
|
376
|
+
try {
|
|
377
|
+
opened = engine.call('lwsOpen', [JSON.stringify(config)])
|
|
378
|
+
} catch (error) {
|
|
379
|
+
if (isUnknownOp(error)) {
|
|
380
|
+
throw new LwsHostError(
|
|
381
|
+
'the loaded l4factoidal WebAssembly module does not carry lwsOpen',
|
|
382
|
+
{ unknownOp: true })
|
|
383
|
+
}
|
|
384
|
+
throw error
|
|
385
|
+
}
|
|
386
|
+
handle = handleOf(opened)
|
|
387
|
+
if (handle === null) {
|
|
388
|
+
throw new LwsHostError('lwsOpen answered with no handle')
|
|
389
|
+
}
|
|
390
|
+
// A storage kept in files: read the files the engine names until it is
|
|
391
|
+
// ready, and perform the plan of the start. Without a DIR, there are no
|
|
392
|
+
// files: the engine is told none is present.
|
|
393
|
+
filesMode = opened !== null && typeof opened === 'object' &&
|
|
394
|
+
opened.load !== undefined && opened.load !== null
|
|
395
|
+
if (filesMode) {
|
|
396
|
+
if (dir === null) throw new LwsHostError('a storage kept in files needs a storage directory (dir)')
|
|
397
|
+
loadFiles(engine, handle, opened, dir)
|
|
398
|
+
}
|
|
399
|
+
// What a server prints at start, after the experimental code notice:
|
|
400
|
+
// a take-over of a stale lock, and whether the storage on disk is
|
|
401
|
+
// sealed (encryption at rest; the engine words both).
|
|
402
|
+
if (dir !== null) {
|
|
403
|
+
if (lock.warning !== null) notices.push(`WARNING: ${lock.warning}`)
|
|
404
|
+
notices.push(atRestNotice(engine, dir, storageKey).notice)
|
|
405
|
+
}
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (lock !== null) lock.release()
|
|
353
408
|
throw error
|
|
354
409
|
}
|
|
355
|
-
const handle = handleOf(opened)
|
|
356
|
-
if (handle === null) {
|
|
357
|
-
throw new LwsHostError('lwsOpen answered with no handle')
|
|
358
|
-
}
|
|
359
|
-
// A storage kept in files: read the files the engine names until it is
|
|
360
|
-
// ready, and perform the plan of the start. Without a DIR, there are no
|
|
361
|
-
// files: the engine is told none is present.
|
|
362
|
-
const filesMode = opened !== null && typeof opened === 'object' &&
|
|
363
|
-
opened.load !== undefined && opened.load !== null
|
|
364
|
-
if (filesMode) {
|
|
365
|
-
if (dir === null) throw new LwsHostError('a storage kept in files needs a storage directory (dir)')
|
|
366
|
-
loadFiles(engine, handle, opened, dir)
|
|
367
|
-
}
|
|
368
410
|
/** After a call whose envelope may say `changed`: write what the engine
|
|
369
411
|
* gives (the snapshot, or the plan of a storage kept in files). */
|
|
370
412
|
const persist = (envelope) => {
|
|
@@ -448,6 +490,10 @@ export async function openLwsStorage (options = {}) {
|
|
|
448
490
|
return {
|
|
449
491
|
engine,
|
|
450
492
|
handle,
|
|
493
|
+
/** The lock file of the storage directory, or null with no DIR. */
|
|
494
|
+
lockFile: lock !== null ? lock.file : null,
|
|
495
|
+
/** The start notices, for the server to print on standard error. */
|
|
496
|
+
notices,
|
|
451
497
|
/**
|
|
452
498
|
* One protocol step.
|
|
453
499
|
* @param {{method: string, target: string,
|
|
@@ -538,6 +584,7 @@ export async function openLwsStorage (options = {}) {
|
|
|
538
584
|
closed = true
|
|
539
585
|
if (flushTimer !== null) clearTimeout(flushTimer)
|
|
540
586
|
try { engine.call('lwsClose', [handle]) } catch (_error) { /* already gone */ }
|
|
587
|
+
if (lock !== null) lock.release()
|
|
541
588
|
}
|
|
542
589
|
}
|
|
543
590
|
}
|
|
@@ -593,6 +640,12 @@ export async function createLwsServer (options = {}) {
|
|
|
593
640
|
* (L4Factoidal/Authz/Mount.lean). */
|
|
594
641
|
function storage () {
|
|
595
642
|
if (opening === null) {
|
|
643
|
+
// What the socket bound, when the caller listened itself and did not
|
|
644
|
+
// say (data for the engine's Host check, issue 1124).
|
|
645
|
+
const bound = server.address()
|
|
646
|
+
if (settings.listen === undefined && bound !== null && typeof bound === 'object') {
|
|
647
|
+
settings = { ...settings, listen: { address: bound.address, port: bound.port, addresses: [] } }
|
|
648
|
+
}
|
|
596
649
|
opening = (async () => {
|
|
597
650
|
let storageSettings = settings
|
|
598
651
|
if (settings.authz !== undefined && settings.authz !== null) {
|
|
@@ -720,7 +773,7 @@ export async function listen (options = {}) {
|
|
|
720
773
|
const bound = created.server.address()
|
|
721
774
|
const origin = `http://${host}:${bound.port}`
|
|
722
775
|
if (typeof options.baseIri !== 'string') created.setBaseIri(`${origin}/`)
|
|
723
|
-
created.setListen({ address: bound.address, port: bound.port, addresses: await interfaceAddresses() })
|
|
776
|
+
created.setListen({ host, address: bound.address, port: bound.port, addresses: await interfaceAddresses() })
|
|
724
777
|
try {
|
|
725
778
|
await created.storage()
|
|
726
779
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@factoidal/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "EXPERIMENTAL UNREVIEWED CODE: do not run public services on it without careful independent expert review. RDF parsing and writing (Turtle, TriG, N-Triples, N-Quads, RDF/XML, JSON-LD), SPARQL 1.1 and 1.2, RDFS and OWL reasoning, SHACL and ShEx validation, CSVW and RML mapping, RDFC-1.0, and SPARQL, LWS and Solid servers, for Node, Deno and browsers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sparql",
|
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
"./store-host/paths": "./store-host/paths.mjs",
|
|
71
71
|
"./xmpp": "./xmpp/index.mjs",
|
|
72
72
|
"./xmpp/server": "./xmpp/server.mjs",
|
|
73
|
+
"./cipher-host": "./lib/cipher-host.mjs",
|
|
73
74
|
"./store": {
|
|
74
75
|
"default": "./bin/store.mjs"
|
|
75
76
|
},
|
package/solid/server/index.mjs
CHANGED
|
@@ -55,8 +55,9 @@
|
|
|
55
55
|
// loader, by the design record's rule that the two conformance classes
|
|
56
56
|
// stay distinct.
|
|
57
57
|
|
|
58
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
58
59
|
import { loadEngine } from '../../bin/engine.mjs'
|
|
59
|
-
import { readSnapshot, persistIfChanged, storageKeyText } from '../../lws/persist.mjs'
|
|
60
|
+
import { readSnapshot, persistIfChanged, storageKeyText, acquireDirLock, atRestNotice } from '../../lws/persist.mjs'
|
|
60
61
|
|
|
61
62
|
/** The ops this host needs from the engine. */
|
|
62
63
|
export const SOLID_SERVER_OPS = ['solidOpen', 'solidStep', 'solidClose', 'solidSnapshot',
|
|
@@ -187,6 +188,11 @@ export async function openSolidStorage (options = {}) {
|
|
|
187
188
|
if (options.auth !== undefined && options.auth !== null) config.auth = options.auth
|
|
188
189
|
if (options.insecureNoAuth === true) config.insecureNoAuth = true
|
|
189
190
|
if (typeof options.enforceWac === 'boolean') config.enforceWac = options.enforceWac
|
|
191
|
+
// The Host and Origin guard with insecureNoAuth (issue 1124): what the
|
|
192
|
+
// socket bound and the operator's names and origins, passed unread.
|
|
193
|
+
if (options.listen !== undefined && options.listen !== null) config.listen = options.listen
|
|
194
|
+
if (Array.isArray(options.allowHosts)) config.allowHosts = options.allowHosts
|
|
195
|
+
if (Array.isArray(options.allowOrigins)) config.allowOrigins = options.allowOrigins
|
|
190
196
|
if (typeof options.rootAcl === 'string') config.rootAcl = options.rootAcl
|
|
191
197
|
// Encryption at rest (issue 921), as the LWS host: the key text from
|
|
192
198
|
// `storageKey` or FACTOIDAL_LWS_KEY, passed unread; the engine decides.
|
|
@@ -195,28 +201,65 @@ export async function openSolidStorage (options = {}) {
|
|
|
195
201
|
// `dir` names the storage directory. The snapshot found there, if any,
|
|
196
202
|
// is the storage to continue from; the engine checks it.
|
|
197
203
|
const dir = typeof options.dir === 'string' ? options.dir : null
|
|
204
|
+
// One writing process per storage directory
|
|
205
|
+
// (https://github.com/danbri/factoidal/issues/1135), as the LWS host: the
|
|
206
|
+
// engine decides; the lock is taken before DIR is read.
|
|
207
|
+
let lock = null
|
|
198
208
|
if (dir !== null) {
|
|
199
|
-
|
|
200
|
-
|
|
209
|
+
try {
|
|
210
|
+
lock = acquireDirLock(engine, dir, 'solid-serve')
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (isUnknownOp(error)) {
|
|
213
|
+
throw new SolidServerHostError(
|
|
214
|
+
'the loaded l4factoidal WebAssembly module does not carry lwsDirLock',
|
|
215
|
+
{ unknownOp: true })
|
|
216
|
+
}
|
|
217
|
+
throw error
|
|
218
|
+
}
|
|
201
219
|
}
|
|
202
220
|
let opened
|
|
221
|
+
let handle
|
|
222
|
+
const notices = []
|
|
203
223
|
try {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
224
|
+
if (dir !== null) {
|
|
225
|
+
const snapshot = readSnapshot(dir)
|
|
226
|
+
if (snapshot !== null) config.snapshot = snapshot
|
|
227
|
+
// The names in DIR, unread: the engine refuses a storage that LWS
|
|
228
|
+
// keeps there (`solidRefusesLws`, Wasm/Ops/Solid.lean); only lws-serve
|
|
229
|
+
// serves an LWS storage.
|
|
230
|
+
config.dirFiles = existsSync(dir) ? readdirSync(dir) : []
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
opened = engine.call('solidOpen', [JSON.stringify(config)])
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (isUnknownOp(error)) {
|
|
236
|
+
throw new SolidServerHostError(
|
|
237
|
+
'the loaded l4factoidal WebAssembly module does not carry solidOpen',
|
|
238
|
+
{ unknownOp: true })
|
|
239
|
+
}
|
|
240
|
+
throw error
|
|
241
|
+
}
|
|
242
|
+
handle = handleOf(opened)
|
|
243
|
+
if (handle === null) {
|
|
244
|
+
throw new SolidServerHostError('solidOpen answered with no handle')
|
|
210
245
|
}
|
|
246
|
+
// The start notices: a take-over of a stale lock, and whether the
|
|
247
|
+
// storage on disk is sealed (encryption at rest).
|
|
248
|
+
if (dir !== null) {
|
|
249
|
+
if (lock.warning !== null) notices.push(`WARNING: ${lock.warning}`)
|
|
250
|
+
notices.push(atRestNotice(engine, dir, storageKey).notice)
|
|
251
|
+
}
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (lock !== null) lock.release()
|
|
211
254
|
throw error
|
|
212
255
|
}
|
|
213
|
-
const handle = handleOf(opened)
|
|
214
|
-
if (handle === null) {
|
|
215
|
-
throw new SolidServerHostError('solidOpen answered with no handle')
|
|
216
|
-
}
|
|
217
256
|
return {
|
|
218
257
|
engine,
|
|
219
258
|
handle,
|
|
259
|
+
/** The lock file of the storage directory, or null with no DIR. */
|
|
260
|
+
lockFile: lock !== null ? lock.file : null,
|
|
261
|
+
/** The start notices, for the server to print on standard error. */
|
|
262
|
+
notices,
|
|
220
263
|
storageIri: typeof opened.storage === 'string' ? opened.storage : null,
|
|
221
264
|
/**
|
|
222
265
|
* One protocol step.
|
|
@@ -258,6 +301,7 @@ export async function openSolidStorage (options = {}) {
|
|
|
258
301
|
},
|
|
259
302
|
close () {
|
|
260
303
|
try { engine.call('solidClose', [handle]) } catch (_error) { /* already gone */ }
|
|
304
|
+
if (lock !== null) lock.release()
|
|
261
305
|
}
|
|
262
306
|
}
|
|
263
307
|
}
|
|
@@ -302,6 +346,10 @@ export async function createSolidServer (options = {}) {
|
|
|
302
346
|
|
|
303
347
|
function storage () {
|
|
304
348
|
if (opening === null) {
|
|
349
|
+
const bound = server.address()
|
|
350
|
+
if (settings.listen === undefined && bound !== null && typeof bound === 'object') {
|
|
351
|
+
settings = { ...settings, listen: { address: bound.address, port: bound.port, addresses: [] } }
|
|
352
|
+
}
|
|
305
353
|
opening = openSolidStorage(settings).then((value) => { opened = value; return value })
|
|
306
354
|
}
|
|
307
355
|
return opening
|
|
@@ -345,6 +393,13 @@ export async function createSolidServer (options = {}) {
|
|
|
345
393
|
}
|
|
346
394
|
settings = { ...settings, baseIri: value }
|
|
347
395
|
},
|
|
396
|
+
/** Pass what the socket bound, before the first request (issue 1124). */
|
|
397
|
+
setListen (value) {
|
|
398
|
+
if (opening !== null) {
|
|
399
|
+
throw new SolidServerHostError('the storage is already open; set listen first')
|
|
400
|
+
}
|
|
401
|
+
settings = { ...settings, listen: value }
|
|
402
|
+
},
|
|
348
403
|
close () {
|
|
349
404
|
return new Promise((resolve) => {
|
|
350
405
|
server.close(() => {
|
|
@@ -380,6 +435,16 @@ function writeHeaders (response, pairs) {
|
|
|
380
435
|
* @returns {Promise<{server: object, storage: Function, port: number,
|
|
381
436
|
* origin: string, close: Function}>}
|
|
382
437
|
*/
|
|
438
|
+
/** The addresses of this machine's interfaces. Pure I/O. */
|
|
439
|
+
async function interfaceAddresses () {
|
|
440
|
+
try {
|
|
441
|
+
const os = await import('node:os')
|
|
442
|
+
return Object.values(os.networkInterfaces()).flat().filter(Boolean).map((i) => i.address)
|
|
443
|
+
} catch (_error) {
|
|
444
|
+
return []
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
383
448
|
export async function listen (options = {}) {
|
|
384
449
|
const port = typeof options.port === 'number' ? options.port : 0
|
|
385
450
|
const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
|
|
@@ -391,6 +456,7 @@ export async function listen (options = {}) {
|
|
|
391
456
|
const bound = created.server.address()
|
|
392
457
|
const origin = `http://${host}:${bound.port}`
|
|
393
458
|
if (typeof options.baseIri !== 'string') created.setBaseIri(`${origin}/`)
|
|
459
|
+
created.setListen({ host, address: bound.address, port: bound.port, addresses: await interfaceAddresses() })
|
|
394
460
|
try {
|
|
395
461
|
await created.storage()
|
|
396
462
|
} catch (error) {
|
package/webdav/server.mjs
CHANGED
|
@@ -15,11 +15,31 @@
|
|
|
15
15
|
// `webdavStep`, then carry what the engine answers: an `lws` request to
|
|
16
16
|
// the local storage it names (no socket: the storage handle's own step),
|
|
17
17
|
// a `fetch` to the URL it names, until it answers a `response`, which
|
|
18
|
-
// this file writes back
|
|
18
|
+
// this file writes back; when the response carries `write` (the state
|
|
19
|
+
// file `.webdav-state.json`: dead properties and locks), replace that file
|
|
20
|
+
// first (a new file in the same directory, mode 0600, then a rename);
|
|
21
|
+
// * with a users file (`--users`, https://github.com/danbri/factoidal/issues/1108),
|
|
22
|
+
// give its path to `webdavOpen` and the bytes the engine names; when the
|
|
23
|
+
// engine answers `need` (a login: one PBKDF2-HMAC-SHA256 derivation), run
|
|
24
|
+
// exactly that WebCrypto call (`answerCipherRequest`, lib/cipher-host.mjs)
|
|
25
|
+
// and give the answer to `webdavResume`; give each request's client
|
|
26
|
+
// address (the socket's `remoteAddress`) to `webdavStep`, for the
|
|
27
|
+
// failed-login limit the engine keeps
|
|
28
|
+
// (https://github.com/danbri/factoidal/issues/1119); pass the limit
|
|
29
|
+
// options unread and print the `warnings` of `ready`;
|
|
30
|
+
// * with `--cert` and `--key`, listen with Node `https` (TLS) instead of
|
|
31
|
+
// `http`;
|
|
32
|
+
// * with `--guest NAME` (https://github.com/danbri/factoidal/issues/1147),
|
|
33
|
+
// pass the name unread: the engine decides that a request with no
|
|
34
|
+
// credentials acts as that user, and which mounts each user reaches.
|
|
35
|
+
//
|
|
36
|
+
// Expert review of the login: https://github.com/danbri/factoidal/issues/1117
|
|
19
37
|
//
|
|
20
38
|
// It makes NO protocol decision. Which files are mounts, what a mount file
|
|
21
39
|
// says, every status code, header field, XML body, lock, property and path
|
|
22
|
-
// mapping
|
|
40
|
+
// mapping, every login decision (which users file lines count, whether a
|
|
41
|
+
// password matches, the login cache, whether Basic may run on this address)
|
|
42
|
+
// is decided in formal/lean4/L4Factoidal/WebDAV/ (iron rule 7 of
|
|
23
43
|
// CLAUDE.md). The one status code written here is 500, for a failure of
|
|
24
44
|
// this host itself. Requests are served one at a time (a queue), because
|
|
25
45
|
// the engine keeps one request in flight per handle.
|
|
@@ -27,14 +47,17 @@
|
|
|
27
47
|
// THE WASM CONTRACT (formal/lean4/Wasm/Ops/WebDAV.lean)
|
|
28
48
|
// webdavOpen [configJson] -> { ok, handle, need }
|
|
29
49
|
// webdavLoad [handle, filesJson] -> { ok, need } | { ok, ready }
|
|
30
|
-
// webdavStep [handle, requestJson] + body -> { ok, response | lws | fetch } + body
|
|
31
|
-
// webdavResume [handle, answerJson] + body -> { ok, response | lws | fetch } + body
|
|
50
|
+
// webdavStep [handle, requestJson] + body -> { ok, response [+ write] | lws | fetch | need } + body
|
|
51
|
+
// webdavResume [handle, answerJson] + body -> { ok, response | lws | fetch | need } + body
|
|
32
52
|
// webdavClose [handle] -> { ok }
|
|
33
53
|
|
|
34
|
-
import {
|
|
35
|
-
|
|
54
|
+
import {
|
|
55
|
+
readFileSync, readdirSync, writeFileSync, existsSync, openSync, writeSync, fsyncSync, closeSync, renameSync, unlinkSync
|
|
56
|
+
} from 'node:fs'
|
|
57
|
+
import { resolve as resolvePath, dirname, basename, join } from 'node:path'
|
|
36
58
|
import { loadEngine } from '../bin/engine.mjs'
|
|
37
59
|
import { openLwsStorage } from '../lws/server.mjs'
|
|
60
|
+
import { answerCipherRequest } from '../lib/cipher-host.mjs'
|
|
38
61
|
|
|
39
62
|
/** The ops this host needs from the engine. */
|
|
40
63
|
export const WEBDAV_OPS = ['webdavOpen', 'webdavLoad', 'webdavStep', 'webdavResume', 'webdavClose']
|
|
@@ -107,12 +130,45 @@ function gather (configDir, need) {
|
|
|
107
130
|
return { files, exists }
|
|
108
131
|
}
|
|
109
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Replace a file the engine named (`write` on a response) atomically: write
|
|
135
|
+
* a new file in the same directory, flush it, then rename it over the old
|
|
136
|
+
* one, so a crash leaves the old text or the new text. Pure I/O.
|
|
137
|
+
*/
|
|
138
|
+
function replaceFile (configDir, write) {
|
|
139
|
+
const target = resolvePath(configDir, write.path)
|
|
140
|
+
const suffix = Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(6))).toString('hex')
|
|
141
|
+
const temp = join(dirname(target), `.${basename(target)}.${process.pid}.${suffix}.tmp`)
|
|
142
|
+
const fd = openSync(temp, 'wx', parseInt(write.mode, 8))
|
|
143
|
+
try {
|
|
144
|
+
writeSync(fd, write.text)
|
|
145
|
+
fsyncSync(fd)
|
|
146
|
+
} finally {
|
|
147
|
+
closeSync(fd)
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
renameSync(temp, target)
|
|
151
|
+
} catch (error) {
|
|
152
|
+
try { unlinkSync(temp) } catch (_error) { /* already gone */ }
|
|
153
|
+
throw error
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
110
157
|
/**
|
|
111
158
|
* Open a WebDAV server handle over the mount files of `configDir`.
|
|
112
159
|
*
|
|
113
160
|
* @param {object} options
|
|
114
161
|
* @param {string} options.configDir the directory of mount files
|
|
115
162
|
* @param {object} [options.engine] an engine handle to reuse
|
|
163
|
+
* @param {string} [options.users] the users file (an absolute path, or one
|
|
164
|
+
* relative to configDir): the listener then needs a login
|
|
165
|
+
* @param {boolean} [options.tls] the listener uses TLS
|
|
166
|
+
* @param {string} [options.host] the address the listener binds
|
|
167
|
+
* @param {string} [options.loginLimit] `--login-limit`, passed unread
|
|
168
|
+
* @param {string} [options.loginWindow] `--login-window`, passed unread
|
|
169
|
+
* @param {boolean} [options.disableRateLimits] `--dangerously-disable-ratelimits`
|
|
170
|
+
* @param {string} [options.guest] `--guest NAME`: the user a request with no
|
|
171
|
+
* credentials acts as (passed unread)
|
|
116
172
|
* @returns {Promise<{handle: string, mounts: object[], step: Function, close: Function}>}
|
|
117
173
|
*/
|
|
118
174
|
export async function openWebdav (options = {}) {
|
|
@@ -120,7 +176,22 @@ export async function openWebdav (options = {}) {
|
|
|
120
176
|
const configDir = options.configDir
|
|
121
177
|
const engine = options.engine !== undefined ? options.engine : await loadEngine()
|
|
122
178
|
const listing = readdirSync(configDir)
|
|
123
|
-
|
|
179
|
+
const login = {
|
|
180
|
+
users: typeof options.users === 'string' ? options.users : null,
|
|
181
|
+
tls: options.tls === true,
|
|
182
|
+
host: typeof options.host === 'string' ? options.host : '127.0.0.1',
|
|
183
|
+
limit: typeof options.loginLimit === 'string' ? options.loginLimit : null,
|
|
184
|
+
window: typeof options.loginWindow === 'string' ? options.loginWindow : null,
|
|
185
|
+
disableRateLimits: options.disableRateLimits === true
|
|
186
|
+
}
|
|
187
|
+
// The guest user (issue 1147), passed unread; the engine checks it.
|
|
188
|
+
if (typeof options.guest === 'string') login.guest = options.guest
|
|
189
|
+
const config = { listing, entropy: randomBase64(32), now: nowSeconds(), login }
|
|
190
|
+
// The Host and Origin guard of the HTTP listener (issue 1124): the bound
|
|
191
|
+
// address and the operator's names and origins, passed unread
|
|
192
|
+
// (L4Factoidal/HTTP/LocalGuard.lean decides).
|
|
193
|
+
if (options.localGuard !== undefined && options.localGuard !== null) config.localGuard = options.localGuard
|
|
194
|
+
let envelope = callEngine(engine, 'webdavOpen', [JSON.stringify(config)])
|
|
124
195
|
if (envelope.ok === false) throw new WebdavHostError(envelope.error, { envelope })
|
|
125
196
|
const handle = envelope.handle
|
|
126
197
|
let loads = 0
|
|
@@ -137,10 +208,25 @@ export async function openWebdav (options = {}) {
|
|
|
137
208
|
}
|
|
138
209
|
// Each local storage, opened as lws-serve opens it; the config is the
|
|
139
210
|
// engine's, passed unread.
|
|
211
|
+
// Each takes the lock of its directory (one writing process per storage
|
|
212
|
+
// directory, https://github.com/danbri/factoidal/issues/1135): a directory
|
|
213
|
+
// that lws-serve or another webdav-serve holds stops the start, and the
|
|
214
|
+
// locks of the mounts opened before it are released.
|
|
140
215
|
const storages = new Map()
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
216
|
+
const storageNotices = []
|
|
217
|
+
try {
|
|
218
|
+
for (const l of ready.local || []) {
|
|
219
|
+
const storage = await openLwsStorage({ engine, dir: resolvePath(configDir, l.dir), ...l.config, realClock: true, lockCommand: 'webdav-serve' })
|
|
220
|
+
storages.set(l.name, storage)
|
|
221
|
+
for (const n of storage.notices || []) storageNotices.push(`mount ${l.name}: ${n}`)
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
for (const s of storages.values()) s.close()
|
|
225
|
+
try { engine.call('webdavClose', [handle]) } catch (_error) { /* already gone */ }
|
|
226
|
+
if (error !== null && typeof error === 'object' && error.envelope !== undefined && !(error instanceof WebdavHostError)) {
|
|
227
|
+
throw new WebdavHostError(error.envelope.error, { envelope: error.envelope })
|
|
228
|
+
}
|
|
229
|
+
throw error
|
|
144
230
|
}
|
|
145
231
|
|
|
146
232
|
/** Carry one `fetch` the engine named. Pure I/O. */
|
|
@@ -168,12 +254,14 @@ export async function openWebdav (options = {}) {
|
|
|
168
254
|
target: request.target,
|
|
169
255
|
headers: request.headers,
|
|
170
256
|
now: nowSeconds(),
|
|
171
|
-
randomBase64: randomBase64(16)
|
|
257
|
+
randomBase64: randomBase64(16),
|
|
258
|
+
remoteAddress: typeof request.remoteAddress === 'string' ? request.remoteAddress : ''
|
|
172
259
|
}
|
|
173
260
|
let got = blobCall(engine, 'webdavStep', [handle, JSON.stringify(record)], request.body || new Uint8Array(0))
|
|
174
261
|
for (let hop = 0; hop < MAX_HOPS; hop += 1) {
|
|
175
262
|
const env = got.envelope
|
|
176
263
|
if (env.response !== undefined) {
|
|
264
|
+
if (env.write !== undefined) replaceFile(configDir, env.write)
|
|
177
265
|
return { status: env.response.status, headers: env.response.headers || [], body: got.bytes }
|
|
178
266
|
}
|
|
179
267
|
let answer
|
|
@@ -189,8 +277,12 @@ export async function openWebdav (options = {}) {
|
|
|
189
277
|
const x = await exchange(env.fetch, got.bytes)
|
|
190
278
|
answer = x.answer
|
|
191
279
|
bytes = x.bytes
|
|
280
|
+
} else if (env.need !== undefined) {
|
|
281
|
+
// A login: the one WebCrypto derivation the engine named.
|
|
282
|
+
answer = await answerCipherRequest(env.need)
|
|
283
|
+
bytes = new Uint8Array(0)
|
|
192
284
|
} else {
|
|
193
|
-
throw new WebdavHostError('the engine answered neither response, lws nor
|
|
285
|
+
throw new WebdavHostError('the engine answered neither response, lws, fetch nor need')
|
|
194
286
|
}
|
|
195
287
|
got = blobCall(engine, 'webdavResume', [handle, JSON.stringify(answer)], bytes)
|
|
196
288
|
}
|
|
@@ -201,9 +293,13 @@ export async function openWebdav (options = {}) {
|
|
|
201
293
|
handle,
|
|
202
294
|
engine,
|
|
203
295
|
mounts: ready.mounts || [],
|
|
296
|
+
warnings: ready.warnings || [],
|
|
297
|
+
/** The start notices of the local mounts (a stale lock taken over,
|
|
298
|
+
* encryption at rest on or off), for standard error. */
|
|
299
|
+
storageNotices,
|
|
204
300
|
/**
|
|
205
301
|
* One request, served after the requests before it.
|
|
206
|
-
* @param {{method: string, target: string, headers: Array<[string, string]>, body: Uint8Array}} request
|
|
302
|
+
* @param {{method: string, target: string, headers: Array<[string, string]>, body: Uint8Array, remoteAddress?: string}} request
|
|
207
303
|
*/
|
|
208
304
|
step (request) {
|
|
209
305
|
const run = queue.then(() => serve(request))
|
|
@@ -217,6 +313,16 @@ export async function openWebdav (options = {}) {
|
|
|
217
313
|
}
|
|
218
314
|
}
|
|
219
315
|
|
|
316
|
+
/** The addresses of this machine's interfaces. Pure I/O. */
|
|
317
|
+
async function interfaceAddresses () {
|
|
318
|
+
try {
|
|
319
|
+
const os = await import('node:os')
|
|
320
|
+
return Object.values(os.networkInterfaces()).flat().filter(Boolean).map((i) => i.address)
|
|
321
|
+
} catch (_error) {
|
|
322
|
+
return []
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
220
326
|
function readBody (request) {
|
|
221
327
|
return new Promise((resolve, reject) => {
|
|
222
328
|
const chunks = []
|
|
@@ -246,20 +352,36 @@ function writeHeaders (response, pairs) {
|
|
|
246
352
|
* Start the server: open the handle, then listen.
|
|
247
353
|
*
|
|
248
354
|
* @param {object} options `configDir`, `port` (default 8090), `host`
|
|
249
|
-
* (default 127.0.0.1), `engine`, `onError`
|
|
355
|
+
* (default 127.0.0.1), `engine`, `onError`, `users` (a users file),
|
|
356
|
+
* `cert` and `key` (PEM bytes: listen with TLS), `loginLimit`,
|
|
357
|
+
* `loginWindow`, `disableRateLimits` (the failed-login limit)
|
|
250
358
|
* @returns {Promise<{server: object, port: number, origin: string, mounts: object[], close: Function}>}
|
|
251
359
|
*/
|
|
252
360
|
export async function listen (options = {}) {
|
|
253
|
-
const
|
|
254
|
-
const
|
|
361
|
+
const tls = options.cert !== undefined && options.key !== undefined
|
|
362
|
+
const http = tls ? await import('node:https') : await import('node:http')
|
|
363
|
+
const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
|
|
364
|
+
const localGuard = {
|
|
365
|
+
host,
|
|
366
|
+
addresses: await interfaceAddresses(),
|
|
367
|
+
...(Array.isArray(options.allowHosts) ? { allowHosts: options.allowHosts } : {}),
|
|
368
|
+
...(Array.isArray(options.allowOrigins) ? { allowOrigins: options.allowOrigins } : {})
|
|
369
|
+
}
|
|
370
|
+
const dav = await openWebdav({ ...options, tls, host, localGuard })
|
|
255
371
|
const onError = typeof options.onError === 'function'
|
|
256
372
|
? options.onError
|
|
257
373
|
: (error) => { console.error(`webdav-serve: ${error.message}`) }
|
|
258
|
-
const
|
|
374
|
+
const handler = async (request, response) => {
|
|
259
375
|
let answer
|
|
260
376
|
try {
|
|
261
377
|
const body = await readBody(request)
|
|
262
|
-
answer = await dav.step({
|
|
378
|
+
answer = await dav.step({
|
|
379
|
+
method: request.method,
|
|
380
|
+
target: request.url,
|
|
381
|
+
headers: headerPairs(request.rawHeaders),
|
|
382
|
+
body,
|
|
383
|
+
remoteAddress: request.socket.remoteAddress
|
|
384
|
+
})
|
|
263
385
|
} catch (error) {
|
|
264
386
|
onError(error)
|
|
265
387
|
response.writeHead(500, { 'content-type': 'text/plain' })
|
|
@@ -269,20 +391,27 @@ export async function listen (options = {}) {
|
|
|
269
391
|
writeHeaders(response, answer.headers)
|
|
270
392
|
response.statusCode = answer.status
|
|
271
393
|
response.end(answer.body)
|
|
272
|
-
}
|
|
394
|
+
}
|
|
395
|
+
const server = tls ? http.createServer({ cert: options.cert, key: options.key }, handler) : http.createServer(handler)
|
|
273
396
|
const port = typeof options.port === 'number' ? options.port : 8090
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
397
|
+
try {
|
|
398
|
+
await new Promise((resolve, reject) => {
|
|
399
|
+
server.once('error', reject)
|
|
400
|
+
server.listen(port, host, resolve)
|
|
401
|
+
})
|
|
402
|
+
} catch (error) {
|
|
403
|
+
dav.close()
|
|
404
|
+
throw error
|
|
405
|
+
}
|
|
279
406
|
const bound = server.address()
|
|
280
|
-
const origin =
|
|
407
|
+
const origin = `${tls ? 'https' : 'http'}://${host.includes(':') ? `[${host}]` : host}:${bound.port}`
|
|
281
408
|
return {
|
|
282
409
|
server,
|
|
283
410
|
port: bound.port,
|
|
284
411
|
origin,
|
|
285
412
|
mounts: dav.mounts,
|
|
413
|
+
warnings: dav.warnings,
|
|
414
|
+
storageNotices: dav.storageNotices,
|
|
286
415
|
close () {
|
|
287
416
|
return new Promise((resolve) => {
|
|
288
417
|
server.close(() => { dav.close(); resolve() })
|