mikser-io-drive 11.2.2 → 11.3.1
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/README.md +80 -0
- package/index.js +83 -2
- package/lib/root-listing.js +120 -0
- package/package.json +3 -3
- package/test/root-listing.test.js +207 -0
package/README.md
CHANGED
|
@@ -149,6 +149,47 @@ creates an empty file** (RFC 4918 §9.10.4). A client that locks before writing
|
|
|
149
149
|
— Finder's Save As does — leaves an empty document behind even if the write
|
|
150
150
|
never arrives.
|
|
151
151
|
|
|
152
|
+
## A domain of its own, and what Windows needs
|
|
153
|
+
|
|
154
|
+
Explorer cannot map a share that lives at `<base>/<name>` on a site whose `/` is
|
|
155
|
+
a static site. The Microsoft WebDAV redirector establishes its session against
|
|
156
|
+
the **server root** before it touches the path: it asks `/`, gets the site's
|
|
157
|
+
answer, and stops — the mount being correct makes no difference. Measured on a
|
|
158
|
+
live host, `OPTIONS /drive/SkinCheck/` answered `DAV: 1, 3, 2` while
|
|
159
|
+
`dir \\host@SSL\DavWWWRoot\drive\SkinCheck\` still failed, and it failed at
|
|
160
|
+
the root.
|
|
161
|
+
|
|
162
|
+
Give the drive a domain and `/` is the drive:
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
drive({
|
|
166
|
+
host: 'drive.example.com',
|
|
167
|
+
auth: identity,
|
|
168
|
+
endpoints: {
|
|
169
|
+
SkinCheck: { folder: 'skincheck' },
|
|
170
|
+
Reports: { folder: 'reports' },
|
|
171
|
+
},
|
|
172
|
+
})
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`\\drive.example.com@SSL\` now mounts, and `/` is a collection whose children
|
|
176
|
+
are the endpoints **that caller may read** — filtered by the same
|
|
177
|
+
`drive:<name>` capability that guards the endpoint itself, so two people
|
|
178
|
+
mapping the same drive see different folders in it. Entering one lands on that
|
|
179
|
+
endpoint's own server with its own authenticator; the root is a listing, not a
|
|
180
|
+
proxy.
|
|
181
|
+
|
|
182
|
+
`OPTIONS /` is answered without credentials, because that is what the
|
|
183
|
+
redirector asks before it has any to offer — it discloses that a DAV server is
|
|
184
|
+
here and nothing about what is in it. The listing is the gated part.
|
|
185
|
+
|
|
186
|
+
Scoped to the Host header, and that is not optional: mounted at `/` for every
|
|
187
|
+
host, a drive would shadow every page on the site. Requests for any other host
|
|
188
|
+
fall straight through.
|
|
189
|
+
|
|
190
|
+
The `<base>/<name>` surface is unchanged and still answers on every host. This
|
|
191
|
+
adds a way in; it does not move the old one.
|
|
192
|
+
|
|
152
193
|
## What the folder is called
|
|
153
194
|
|
|
154
195
|
The endpoint key is the name, and the mount reports it as `displayname`:
|
|
@@ -312,6 +353,45 @@ class of problem from Nephele's four known gaps, and it carries 65 open issues
|
|
|
312
353
|
against Nephele's zero. Nephele is also Apache-2.0 and load-bearing for its
|
|
313
354
|
author's own product, which is the maintenance signal that matters most.
|
|
314
355
|
|
|
356
|
+
**Writing it here was considered and is not close.** RFC 4918 is about 4,650
|
|
357
|
+
lines in Nephele — 2,994 of method handlers and 1,662 of machinery — and the
|
|
358
|
+
weight is not where it looks:
|
|
359
|
+
|
|
360
|
+
| | lines | |
|
|
361
|
+
| --- | --- | --- |
|
|
362
|
+
| `Method.js` | 930 | the base class every method inherits |
|
|
363
|
+
| — `checkIfHeader` | 234 | the `If:` header alone |
|
|
364
|
+
| — `checkConditionalHeaders` | 77 | |
|
|
365
|
+
| `LOCK` + `UNLOCK` | 442 | plus 81 lock references threaded through `Method.js` |
|
|
366
|
+
| `MOVE` + `COPY` | 438 | `Depth` and `Overwrite` semantics |
|
|
367
|
+
| `PROPFIND` | 276 | |
|
|
368
|
+
| `GET`/`HEAD` | 286 | ranges, etags |
|
|
369
|
+
|
|
370
|
+
Listing files is the cheap part. `If:` is a grammar rather than a value —
|
|
371
|
+
tagged and untagged lists, `Not`, lock tokens and etags mixed, evaluated
|
|
372
|
+
against resources other than the request target — and getting it wrong means
|
|
373
|
+
Finder's Save As either walks over somebody's lock or refuses forever. Locking
|
|
374
|
+
is not isolated in `LOCK.js` either: provisional locks, timeouts, per-user lock
|
|
375
|
+
permission and token extraction run on every write method.
|
|
376
|
+
|
|
377
|
+
And the litmus column above is what would be given up: 95 of 104 on
|
|
378
|
+
`meta-files`, with four known gaps named and pinned. A hand-written server
|
|
379
|
+
starts at zero and earns each point by a client breaking in someone's hands.
|
|
380
|
+
|
|
381
|
+
The read-only case is genuinely different — `OPTIONS` + `PROPFIND` + `GET` with
|
|
382
|
+
no locking and no conditionals is a few hundred lines, and the drive root in
|
|
383
|
+
`lib/root-listing.js` is already that shape. It is not the case anyone is
|
|
384
|
+
asking for.
|
|
385
|
+
|
|
386
|
+
**The sharper version of the question is the ADAPTER, not the engine.** Three
|
|
387
|
+
things have been worked around here, and all three are in
|
|
388
|
+
`@nephele/adapter-file-system` rather than in the protocol: `displayname`
|
|
389
|
+
commented out of its live-property list with a TODO, a flat `max-age=604800` on
|
|
390
|
+
the OPTIONS response, and a read-only mount advertising `PUT` in `Allow`. The
|
|
391
|
+
adapter answers "what is at this path and what are its properties", which is
|
|
392
|
+
the thin half. If it keeps costing, replace it and keep the 4,650 lines that
|
|
393
|
+
matter.
|
|
394
|
+
|
|
315
395
|
Staying on Nephele. If its gaps ever become the binding constraint, the
|
|
316
396
|
serious alternative is not another npm package — it is **Apache `mod_dav`**,
|
|
317
397
|
the implementation litmus was written to test, which reads `htpasswd` and
|
package/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { MikserAuthenticator } from './lib/authenticator.js'
|
|
|
5
5
|
import { registerFileTools } from './lib/files.js'
|
|
6
6
|
import { withStagedWrites, stageWrites } from './lib/staged-writes.js'
|
|
7
7
|
import { withDisplayName } from './lib/display-name.js'
|
|
8
|
+
import { rootListing } from './lib/root-listing.js'
|
|
8
9
|
|
|
9
10
|
export { MikserAuthenticator, withStagedWrites, stageWrites }
|
|
10
11
|
|
|
@@ -48,6 +49,10 @@ export const writeCapability = (name) => `drive:${name}:write`
|
|
|
48
49
|
* q3: { folder: 'q3', displayName: 'Q3 reports' },
|
|
49
50
|
* },
|
|
50
51
|
* auth: identity,
|
|
52
|
+
* // A domain of its own, where `/` lists the endpoints the caller
|
|
53
|
+
* // may read. Belongs to the DRIVE, not to one endpoint: the whole
|
|
54
|
+
* // point is that several endpoints share it.
|
|
55
|
+
* host: 'drive.example.com',
|
|
51
56
|
* })
|
|
52
57
|
*
|
|
53
58
|
* One Nephele server per endpoint, mounted at `<base>/<name>` — the same
|
|
@@ -66,6 +71,19 @@ export function drive(options = {}) {
|
|
|
66
71
|
const {
|
|
67
72
|
base = '/drive',
|
|
68
73
|
endpoints = {},
|
|
74
|
+
// A domain of its own. On it, `/` is the drive: a collection listing
|
|
75
|
+
// the endpoints the caller may read, with each endpoint mounted
|
|
76
|
+
// beneath it at `/<name>`.
|
|
77
|
+
//
|
|
78
|
+
// The Microsoft WebDAV redirector establishes its session against the
|
|
79
|
+
// SERVER ROOT before it touches the path, so a share at
|
|
80
|
+
// `<base>/<name>` on a site whose `/` is a static site is one Explorer
|
|
81
|
+
// may never reach. Given a host, `\\drive.example.com@SSL\` is the
|
|
82
|
+
// mount and the public site is untouched.
|
|
83
|
+
//
|
|
84
|
+
// The `<base>/<name>` surface is unchanged and still answers on every
|
|
85
|
+
// host — this adds a way in, it does not move the old one.
|
|
86
|
+
host,
|
|
69
87
|
auth,
|
|
70
88
|
realm = 'mikser',
|
|
71
89
|
// Nephele defaults both to 'meta-files', which writes sidecars INTO
|
|
@@ -123,10 +141,25 @@ export function drive(options = {}) {
|
|
|
123
141
|
'terminate TLS in front of mikser.', runtime.options.url)
|
|
124
142
|
}
|
|
125
143
|
|
|
144
|
+
// What the drive's own root will list, if it has a host.
|
|
145
|
+
const rootEntries = []
|
|
146
|
+
|
|
126
147
|
for (const [name, ep] of Object.entries(endpoints)) {
|
|
127
148
|
if (!ep.folder) {
|
|
128
149
|
throw new Error(`drive: endpoint ${JSON.stringify(name)} declares no folder`)
|
|
129
150
|
}
|
|
151
|
+
// `host` belongs to the drive, not to an endpoint — several
|
|
152
|
+
// endpoints share one domain, and `/` on it lists them. Said
|
|
153
|
+
// out loud because the alternative is a config that reads as
|
|
154
|
+
// though it works and does nothing: this option was
|
|
155
|
+
// per-endpoint while it was being designed, and the example in
|
|
156
|
+
// this file's own JSDoc showed it that way after it moved.
|
|
157
|
+
if (ep.host) {
|
|
158
|
+
logger.warn(
|
|
159
|
+
'drive: endpoint %j sets `host`, which is a plugin-level option and is ignored here. '
|
|
160
|
+
+ 'Move it beside `endpoints`: drive({ host: %j, endpoints: { … } }).',
|
|
161
|
+
name, ep.host)
|
|
162
|
+
}
|
|
130
163
|
const root = resolve(ep.folder)
|
|
131
164
|
const mountPath = `${base}/${name}`
|
|
132
165
|
|
|
@@ -209,7 +242,7 @@ export function drive(options = {}) {
|
|
|
209
242
|
// property is found and bound on either path.
|
|
210
243
|
fsAdapter.getOptionsResponseCacheControl = async () => 'no-cache'
|
|
211
244
|
|
|
212
|
-
|
|
245
|
+
const mountServer = nepheleServer({
|
|
213
246
|
// Writes are staged to a sibling temp file and renamed.
|
|
214
247
|
// The adapter writes straight to the destination with
|
|
215
248
|
// open(path,'w'), which truncates immediately — so the
|
|
@@ -250,7 +283,24 @@ export function drive(options = {}) {
|
|
|
250
283
|
// in the authenticator, because Nephele resolves plugins
|
|
251
284
|
// BEFORE it authenticates and the hook cannot see the user.
|
|
252
285
|
plugins: readOnly ? [new ReadOnlyPlugin()] : [],
|
|
253
|
-
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
app.use(mountPath, mountServer)
|
|
289
|
+
|
|
290
|
+
// The same server, reached from the drive's own domain.
|
|
291
|
+
// Scoped to the Host header, and that is not optional: mounted
|
|
292
|
+
// at `/<name>` for every host, it would shadow a real page.
|
|
293
|
+
if (host) {
|
|
294
|
+
app.use(`/${name}`, (req, res, next) =>
|
|
295
|
+
(req.hostname === host ? mountServer(req, res, next) : next()))
|
|
296
|
+
// Named for the client with the same rule the mount uses,
|
|
297
|
+
// so the listing and the folder agree.
|
|
298
|
+
rootEntries.push({
|
|
299
|
+
name,
|
|
300
|
+
displayName: ep.displayName ?? name,
|
|
301
|
+
capability: readCapability(name),
|
|
302
|
+
})
|
|
303
|
+
}
|
|
254
304
|
|
|
255
305
|
registerRoute({
|
|
256
306
|
path: mountPath,
|
|
@@ -318,6 +368,37 @@ export function drive(options = {}) {
|
|
|
318
368
|
})
|
|
319
369
|
}
|
|
320
370
|
|
|
371
|
+
// The drive's own root, last: it answers only for `/`, and only
|
|
372
|
+
// on its host, so it has to sit behind the endpoint mounts rather
|
|
373
|
+
// than in front of them.
|
|
374
|
+
if (host) {
|
|
375
|
+
app.use((req, res, next) =>
|
|
376
|
+
(req.hostname === host
|
|
377
|
+
? rootListing({
|
|
378
|
+
entries: rootEntries,
|
|
379
|
+
rootName: options.displayName ?? host,
|
|
380
|
+
auth: {
|
|
381
|
+
verifier: resolveAuth(auth),
|
|
382
|
+
allowRemote: true,
|
|
383
|
+
trustLoopback: !auth,
|
|
384
|
+
realm,
|
|
385
|
+
},
|
|
386
|
+
logger,
|
|
387
|
+
})(req, res, next)
|
|
388
|
+
: next()))
|
|
389
|
+
|
|
390
|
+
registerRoute({
|
|
391
|
+
path: '/',
|
|
392
|
+
host,
|
|
393
|
+
plugin: 'drive',
|
|
394
|
+
reachability: reachabilityOf({ auth: resolveAuth(auth), allowRemote: true }),
|
|
395
|
+
methods: ['OPTIONS', 'PROPFIND'],
|
|
396
|
+
cors: false,
|
|
397
|
+
label: 'WebDAV root',
|
|
398
|
+
detail: `(${names.length} endpoint${names.length === 1 ? '' : 's'})`,
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
321
402
|
logger.info('WebDAV mounted at %s (%s)', base, names.join(', '))
|
|
322
403
|
|
|
323
404
|
// File operations over MCP, for an agent with no route to the
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The drive's own root: a collection whose children are the endpoints you may
|
|
2
|
+
// read.
|
|
3
|
+
//
|
|
4
|
+
// Only reachable on a dedicated host (`drive({ host: … })`), and that is the
|
|
5
|
+
// point of it. The Microsoft WebDAV redirector establishes its session against
|
|
6
|
+
// the SERVER ROOT before it touches the path, so a share at `<base>/<name>` on
|
|
7
|
+
// a site whose `/` is a static site is one Explorer may never reach — it asks
|
|
8
|
+
// `/`, gets a static site's answer, and stops. Given a domain of its own, `/`
|
|
9
|
+
// is the drive, and a listing there is what makes several endpoints reachable
|
|
10
|
+
// through one mount.
|
|
11
|
+
//
|
|
12
|
+
// Deliberately NOT Nephele's multi-mount virtual root, for the reasons in this
|
|
13
|
+
// plugin's header: no virtual-adapter dependency, and no fake directory tree
|
|
14
|
+
// whose names have to be kept in step with the mount keys. The children here
|
|
15
|
+
// ARE the mount keys, and entering one lands on that endpoint's own Nephele
|
|
16
|
+
// server with its own authenticator — so per-endpoint auth stays exactly where
|
|
17
|
+
// it was rather than hanging off a shared root.
|
|
18
|
+
|
|
19
|
+
import { authorize, hasCapability } from 'mikser-io'
|
|
20
|
+
|
|
21
|
+
const escape = (text) => String(text)
|
|
22
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
23
|
+
|
|
24
|
+
const collection = (href, displayName) =>
|
|
25
|
+
` <D:response>\n`
|
|
26
|
+
+ ` <D:href>${escape(href)}</D:href>\n`
|
|
27
|
+
+ ` <D:propstat>\n`
|
|
28
|
+
+ ` <D:prop>\n`
|
|
29
|
+
+ ` <D:resourcetype><D:collection/></D:resourcetype>\n`
|
|
30
|
+
+ ` <D:displayname>${escape(displayName)}</D:displayname>\n`
|
|
31
|
+
+ ` </D:prop>\n`
|
|
32
|
+
+ ` <D:status>HTTP/1.1 200 OK</D:status>\n`
|
|
33
|
+
+ ` </D:propstat>\n`
|
|
34
|
+
+ ` </D:response>`
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Middleware serving OPTIONS and PROPFIND for the host root.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} input
|
|
40
|
+
* @param {Array} input.entries [{ name, displayName, capability }]
|
|
41
|
+
* @param {string} input.rootName what the root collection calls itself
|
|
42
|
+
* @param {object} input.auth { verifier, allowRemote, trustLoopback, realm }
|
|
43
|
+
* @param {object} input.logger
|
|
44
|
+
*/
|
|
45
|
+
export function rootListing({ entries, rootName, auth, logger }) {
|
|
46
|
+
return async (req, res, next) => {
|
|
47
|
+
// Only the root itself. Everything below is a real endpoint mount.
|
|
48
|
+
if (req.path !== '/') return next()
|
|
49
|
+
|
|
50
|
+
if (req.method === 'OPTIONS') {
|
|
51
|
+
// Answered WITHOUT authentication, on purpose: this is discovery,
|
|
52
|
+
// and it is what the redirector asks before it has credentials to
|
|
53
|
+
// offer. It discloses that a WebDAV server is here and nothing
|
|
54
|
+
// about what is in it — the listing below is the gated part.
|
|
55
|
+
//
|
|
56
|
+
// Class 1 only. This collection supports reading its own children
|
|
57
|
+
// and nothing else; the endpoints inside it advertise their own
|
|
58
|
+
// classes, including locking.
|
|
59
|
+
res.set('DAV', '1')
|
|
60
|
+
res.set('MS-Author-Via', 'DAV')
|
|
61
|
+
res.set('Allow', 'OPTIONS, PROPFIND')
|
|
62
|
+
// No caching of a discovery answer — the same rule as the mounts,
|
|
63
|
+
// and for the same reason: a client that cached a refusal keeps it.
|
|
64
|
+
res.set('Cache-Control', 'no-cache')
|
|
65
|
+
return res.status(200).end()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (req.method !== 'PROPFIND') return next()
|
|
69
|
+
|
|
70
|
+
let outcome
|
|
71
|
+
try {
|
|
72
|
+
outcome = await authorize(req, auth.verifier, {
|
|
73
|
+
allowRemote: auth.allowRemote,
|
|
74
|
+
trustLoopback: auth.trustLoopback,
|
|
75
|
+
})
|
|
76
|
+
} catch (err) {
|
|
77
|
+
logger?.error?.('drive: verifier threw at the root — %s', err.message)
|
|
78
|
+
return res.status(401)
|
|
79
|
+
.set('WWW-Authenticate', `Basic realm="${auth.realm}", charset="UTF-8"`)
|
|
80
|
+
.end()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!outcome.ok) {
|
|
84
|
+
// Basic, because it is the only scheme Explorer and Finder speak,
|
|
85
|
+
// and without the challenge a DAV client never prompts.
|
|
86
|
+
if (outcome.status === 401) {
|
|
87
|
+
return res.status(401)
|
|
88
|
+
.set('WWW-Authenticate', `Basic realm="${auth.realm}", charset="UTF-8"`)
|
|
89
|
+
.end()
|
|
90
|
+
}
|
|
91
|
+
return res.status(403).end()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// What this user may read. A `capability: null` entry is not
|
|
95
|
+
// capability-scoped — no map configured, or a bare static token —
|
|
96
|
+
// which passes, the same rule every other surface applies.
|
|
97
|
+
const visible = entries.filter(entry =>
|
|
98
|
+
!entry.capability || hasCapability(outcome.principal, entry.capability))
|
|
99
|
+
|
|
100
|
+
// Depth 0 describes the collection itself; anything else lists the
|
|
101
|
+
// children. Depth: infinity is answered as 1 rather than refused:
|
|
102
|
+
// there is exactly one level here, so they are the same answer.
|
|
103
|
+
const depth = String(req.get('depth') ?? 'infinity')
|
|
104
|
+
const responses = [collection('/', rootName)]
|
|
105
|
+
if (depth !== '0') {
|
|
106
|
+
for (const entry of visible) {
|
|
107
|
+
responses.push(collection(`/${entry.name}/`, entry.displayName))
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
logger?.debug?.('drive: root listing for %j — %d of %d endpoint(s)',
|
|
112
|
+
outcome.principal?.subject, visible.length, entries.length)
|
|
113
|
+
|
|
114
|
+
res.status(207)
|
|
115
|
+
.set('Content-Type', 'application/xml; charset=utf-8')
|
|
116
|
+
.set('DAV', '1')
|
|
117
|
+
.send(`<?xml version="1.0" encoding="utf-8"?>\n`
|
|
118
|
+
+ `<D:multistatus xmlns:D="DAV:">\n${responses.join('\n')}\n</D:multistatus>\n`)
|
|
119
|
+
}
|
|
120
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-drive",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.3.1",
|
|
4
4
|
"description": "WebDAV for mikser-io. Exposes working-folder directories as WebDAV endpoints via Nephele, authenticated through mikser-io-auth's htpasswd identity — so content can be edited from Finder, Explorer or any DAV client and the build picks the change up.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"homepage": "https://github.com/almero-digital-marketing/mikser-io-drive#readme",
|
|
30
30
|
"peerDependencies": {
|
|
31
|
-
"mikser-io": "^11.
|
|
31
|
+
"mikser-io": "^11.7.0",
|
|
32
32
|
"zod": "^4.0.0"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"bcryptjs": "^3.0.0",
|
|
42
42
|
"express": "^5.2.1",
|
|
43
43
|
"globby": "^15.0.0",
|
|
44
|
-
"mikser-io": "^11.
|
|
44
|
+
"mikser-io": "^11.7.0",
|
|
45
45
|
"mikser-io-auth": "^11.0.1",
|
|
46
46
|
"mikser-io-mcp": "^11.0.1",
|
|
47
47
|
"webdav": "^5.10.0",
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// The drive's own root, on a domain of its own.
|
|
2
|
+
//
|
|
3
|
+
// The Microsoft WebDAV redirector establishes its session against the SERVER
|
|
4
|
+
// ROOT before it touches the path. A share at `<base>/<name>` on a site whose
|
|
5
|
+
// `/` is a static site is therefore one Explorer may never reach: it asks `/`,
|
|
6
|
+
// gets a static site's answer, and stops — the leaf being correct makes no
|
|
7
|
+
// difference. Measured on a live host: OPTIONS on the mount answered
|
|
8
|
+
// `DAV: 1, 3, 2` while `dir \\host@SSL\DavWWWRoot\drive\SkinCheck\` still
|
|
9
|
+
// failed, and it failed at the root.
|
|
10
|
+
//
|
|
11
|
+
// `drive({ host })` gives the drive a domain where `/` is the drive itself: a
|
|
12
|
+
// collection whose children are the endpoints the caller may read. Each child
|
|
13
|
+
// is the real mount, so entering one lands on that endpoint's own Nephele
|
|
14
|
+
// server with its own authenticator — no virtual adapter, and no fake
|
|
15
|
+
// directory tree to keep in step with the mount keys.
|
|
16
|
+
//
|
|
17
|
+
// Host-scoped, and that is not optional: mounted at `/` for every host, a
|
|
18
|
+
// drive shadows every page on the site.
|
|
19
|
+
|
|
20
|
+
import { describe, it, before, after } from 'node:test'
|
|
21
|
+
import assert from 'node:assert/strict'
|
|
22
|
+
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises'
|
|
23
|
+
import { tmpdir } from 'node:os'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
import bcrypt from 'bcryptjs'
|
|
26
|
+
import http from 'node:http'
|
|
27
|
+
|
|
28
|
+
import { runtime } from 'mikser-io'
|
|
29
|
+
import { auth } from 'mikser-io-auth'
|
|
30
|
+
import { drive } from '../index.js'
|
|
31
|
+
|
|
32
|
+
const HOST = 'drive.example.test'
|
|
33
|
+
let server, port, dir
|
|
34
|
+
|
|
35
|
+
const as = (user) => 'Basic ' + Buffer.from(`${user}:${user}-pw`).toString('base64')
|
|
36
|
+
|
|
37
|
+
// Raw http, not fetch.
|
|
38
|
+
//
|
|
39
|
+
// The Host header IS the test here — it is what decides whether this request
|
|
40
|
+
// is for the drive's domain — and fetch() silently drops it: `Host` is a
|
|
41
|
+
// forbidden header in undici, so every request went out as 127.0.0.1 and the
|
|
42
|
+
// host gate correctly refused all of them. The first version of this file
|
|
43
|
+
// failed for that reason and the code was fine.
|
|
44
|
+
function request(method, urlPath, { host = HOST, headers = {} } = {}) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const req = http.request(
|
|
47
|
+
{ host: '127.0.0.1', port, path: urlPath, method, headers: { Host: host, ...headers } },
|
|
48
|
+
(res) => {
|
|
49
|
+
let body = ''
|
|
50
|
+
res.on('data', (chunk) => { body += chunk })
|
|
51
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }))
|
|
52
|
+
})
|
|
53
|
+
req.on('error', reject)
|
|
54
|
+
req.end()
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
const propfind = (opts = {}) =>
|
|
58
|
+
request('PROPFIND', '/', { ...opts, headers: { depth: '1', ...(opts.headers ?? {}) } })
|
|
59
|
+
|
|
60
|
+
before(async () => {
|
|
61
|
+
dir = await mkdtemp(path.join(tmpdir(), 'mikser-drive-root-'))
|
|
62
|
+
for (const folder of ['documents', 'reports', 'media', 'runtime']) {
|
|
63
|
+
await mkdir(path.join(dir, folder), { recursive: true })
|
|
64
|
+
}
|
|
65
|
+
await writeFile(path.join(dir, 'documents/page.md'), '# hello\n')
|
|
66
|
+
await writeFile(path.join(dir, 'users.htpasswd'), ['alice', 'bob', 'carol']
|
|
67
|
+
.map(u => `${u}:${bcrypt.hashSync(`${u}-pw`, 10)}`).join('\n') + '\n')
|
|
68
|
+
await writeFile(path.join(dir, 'groups.htgroup'),
|
|
69
|
+
'editors: alice\nreviewers: bob\nowners: carol\n')
|
|
70
|
+
|
|
71
|
+
const { default: express } = await import('express')
|
|
72
|
+
const app = express()
|
|
73
|
+
runtime.options = { ...runtime.options, app, workingFolder: dir, runtimeFolder: path.join(dir, 'runtime') }
|
|
74
|
+
runtime.config = { ...runtime.config }
|
|
75
|
+
runtime.engine = { ...runtime.engine, logger: { info(){}, warn(){}, error(){}, debug(){}, trace(){}, fatal(){} } }
|
|
76
|
+
|
|
77
|
+
const identity = auth({
|
|
78
|
+
capabilities: {
|
|
79
|
+
editors: ['drive:SkinCheck'], // alice: one endpoint
|
|
80
|
+
reviewers: ['drive:Reports'], // bob: a different one
|
|
81
|
+
owners: ['*'], // carol: all of them
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
const plugin = drive({
|
|
85
|
+
host: HOST,
|
|
86
|
+
auth: identity,
|
|
87
|
+
endpoints: {
|
|
88
|
+
SkinCheck: { folder: 'documents' },
|
|
89
|
+
Reports: { folder: 'reports' },
|
|
90
|
+
Media: { folder: 'media', displayName: 'Media library' },
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const load = [], loaded = []
|
|
95
|
+
const core = {
|
|
96
|
+
runtime,
|
|
97
|
+
onLoad: (cb) => load.push(cb),
|
|
98
|
+
onLoaded: (cb) => loaded.push(cb),
|
|
99
|
+
useLogger: () => ({ info(){}, warn(){}, error(){}, debug(){}, trace(){} }),
|
|
100
|
+
}
|
|
101
|
+
identity(core)
|
|
102
|
+
plugin(core)
|
|
103
|
+
for (const cb of load) await cb()
|
|
104
|
+
for (const cb of loaded) await cb()
|
|
105
|
+
|
|
106
|
+
server = await new Promise(resolve => { const s = app.listen(0, () => resolve(s)) })
|
|
107
|
+
port = server.address().port
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
after(async () => {
|
|
111
|
+
server?.closeAllConnections?.()
|
|
112
|
+
await new Promise(r => server?.close(r))
|
|
113
|
+
await rm(dir, { recursive: true, force: true })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('host in the wrong place', () => {
|
|
117
|
+
// It was per-endpoint while this was being designed, and moved to the
|
|
118
|
+
// plugin when the root listing arrived — several endpoints share one
|
|
119
|
+
// domain, and `/` on it lists them. This file's own JSDoc went on showing
|
|
120
|
+
// the old shape, which is a config that reads as though it works and does
|
|
121
|
+
// nothing at all. So it says so.
|
|
122
|
+
it('warns instead of silently ignoring it', async () => {
|
|
123
|
+
const said = []
|
|
124
|
+
const core = {
|
|
125
|
+
runtime,
|
|
126
|
+
onLoad: () => {},
|
|
127
|
+
onLoaded: (cb) => said.push(cb),
|
|
128
|
+
useLogger: () => ({
|
|
129
|
+
info(){}, error(){}, debug(){}, trace(){},
|
|
130
|
+
warn: (...args) => said.push(args.join(' ')),
|
|
131
|
+
}),
|
|
132
|
+
}
|
|
133
|
+
drive({ endpoints: { Share: { folder: 'documents', host: 'drive.example.test' } } })(core)
|
|
134
|
+
for (const entry of [...said]) if (typeof entry === 'function') await entry()
|
|
135
|
+
|
|
136
|
+
const warning = said.find(entry =>
|
|
137
|
+
typeof entry === 'string' && entry.includes('plugin-level option'))
|
|
138
|
+
assert.ok(warning, `no warning about a misplaced host:\n${said.filter(e => typeof e === 'string').join('\n')}`)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('the drive root on its own host', () => {
|
|
143
|
+
it('announces itself as WebDAV, which is what the redirector asks first', async () => {
|
|
144
|
+
// Answered without credentials on purpose: this is discovery, and it
|
|
145
|
+
// is what the redirector asks BEFORE it has anything to offer. It
|
|
146
|
+
// discloses that a DAV server is here and nothing about what is in it.
|
|
147
|
+
const res = await request('OPTIONS', '/')
|
|
148
|
+
assert.equal(res.status, 200)
|
|
149
|
+
assert.match(res.headers.dav ?? '', /\b1\b/, 'the root must claim class 1')
|
|
150
|
+
assert.match(res.headers.allow ?? '', /PROPFIND/)
|
|
151
|
+
assert.equal(res.headers['cache-control'], 'no-cache',
|
|
152
|
+
'a discovery answer that can be cached is one a client keeps refusing')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('challenges an anonymous listing, so a DAV client prompts', async () => {
|
|
156
|
+
const res = await propfind()
|
|
157
|
+
assert.equal(res.status, 401)
|
|
158
|
+
assert.match(res.headers['www-authenticate'] ?? '', /^Basic /,
|
|
159
|
+
'Basic is the only scheme Explorer and Finder speak')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('lists only the endpoints the user may read', async () => {
|
|
163
|
+
const xml = (await propfind({ headers: { authorization: as('alice') } })).body
|
|
164
|
+
assert.match(xml, /<D:href>\/SkinCheck\/<\/D:href>/, xml)
|
|
165
|
+
assert.doesNotMatch(xml, /Reports/, 'bob\'s endpoint is not alice\'s to see')
|
|
166
|
+
assert.doesNotMatch(xml, /Media/, 'nor one nobody granted her')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('shows a different user a different drive', async () => {
|
|
170
|
+
const xml = (await propfind({ headers: { authorization: as('bob') } })).body
|
|
171
|
+
assert.match(xml, /<D:href>\/Reports\/<\/D:href>/, xml)
|
|
172
|
+
assert.doesNotMatch(xml, /SkinCheck/, xml)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('gives a wildcard holder everything, under its display name', async () => {
|
|
176
|
+
const xml = (await propfind({ headers: { authorization: as('carol') } })).body
|
|
177
|
+
for (const name of ['SkinCheck', 'Reports', 'Media']) {
|
|
178
|
+
assert.match(xml, new RegExp(`<D:href>/${name}/</D:href>`), `${name} missing:\n${xml}`)
|
|
179
|
+
}
|
|
180
|
+
assert.match(xml, /<D:displayname>Media library<\/D:displayname>/,
|
|
181
|
+
'the listing uses the same name the mount reports')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('describes only itself at Depth 0', async () => {
|
|
185
|
+
const xml = (await propfind({ headers: { authorization: as('carol'), depth: '0' } })).body
|
|
186
|
+
assert.match(xml, /<D:href>\/<\/D:href>/)
|
|
187
|
+
assert.doesNotMatch(xml, /SkinCheck/, 'Depth 0 is the collection, not its children')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('leaves every other host alone', async () => {
|
|
191
|
+
// The whole safety of mounting at `/`. Without the Host gate a drive
|
|
192
|
+
// shadows every page on the site.
|
|
193
|
+
for (const method of ['OPTIONS', 'PROPFIND']) {
|
|
194
|
+
const res = await request(method, '/', { host: 'www.example.test', headers: { depth: '1' } })
|
|
195
|
+
assert.notEqual(res.status, 207, `${method} answered for the wrong host`)
|
|
196
|
+
assert.equal(res.headers.dav, undefined, `${method} advertised DAV to the wrong host`)
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('serves the endpoint itself beneath the root', async () => {
|
|
201
|
+
const res = await request('PROPFIND', '/SkinCheck/', {
|
|
202
|
+
headers: { depth: '1', authorization: as('alice') },
|
|
203
|
+
})
|
|
204
|
+
assert.equal(res.status, 207, res.body)
|
|
205
|
+
assert.match(res.body, /page\.md/, 'entering an endpoint reaches its real files')
|
|
206
|
+
})
|
|
207
|
+
})
|