mikser-io-drive 11.1.0 → 11.3.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/.github/workflows/publish.yml +9 -0
- package/README.md +72 -0
- package/index.js +98 -9
- package/lib/display-name.js +95 -0
- package/lib/root-listing.js +120 -0
- package/package.json +3 -3
- package/test/atomic-writes.test.js +13 -0
- package/test/derived.test.js +21 -1
- package/test/drive.test.js +13 -0
- package/test/protocol.test.js +68 -0
- package/test/real-client.test.js +13 -0
- package/test/root-listing.test.js +181 -0
|
@@ -23,6 +23,15 @@ on:
|
|
|
23
23
|
jobs:
|
|
24
24
|
publish:
|
|
25
25
|
runs-on: ubuntu-latest
|
|
26
|
+
# A hung test must not cost six hours.
|
|
27
|
+
#
|
|
28
|
+
# Without this the job runs to GitHub's own limit, reporting nothing:
|
|
29
|
+
# a flaky teardown in one suite sat on `npm test` for seventeen
|
|
30
|
+
# minutes before anyone looked, and would have sat there all day.
|
|
31
|
+
# The longest suite in the family is a couple of minutes including
|
|
32
|
+
# install, so fifteen is generous and still fails while someone is
|
|
33
|
+
# still watching.
|
|
34
|
+
timeout-minutes: 15
|
|
26
35
|
permissions:
|
|
27
36
|
contents: read
|
|
28
37
|
# The whole mechanism. Without it npm has nothing to verify.
|
package/README.md
CHANGED
|
@@ -149,6 +149,78 @@ 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
|
+
|
|
193
|
+
## What the folder is called
|
|
194
|
+
|
|
195
|
+
The endpoint key is the name, and the mount reports it as `displayname`:
|
|
196
|
+
|
|
197
|
+
```js
|
|
198
|
+
drive({
|
|
199
|
+
endpoints: {
|
|
200
|
+
SkinCheck: { folder: 'skincheck' }, // shows as "SkinCheck"
|
|
201
|
+
reports: { folder: 'data', displayName: 'Q3 reports' },
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Without it the label is whatever the client derives from the URL, and that is
|
|
207
|
+
not controllable: Express matches routes case-insensitively, so
|
|
208
|
+
`/drive/SkinCheck` and `/drive/skincheck` reach the same mount and the casing
|
|
209
|
+
shown is whichever the client asked for first — an endpoint renamed to
|
|
210
|
+
`SkinCheck` kept reading `skincheck` in Explorer, and remapping did not change
|
|
211
|
+
it. `@nephele/adapter-file-system` does not implement `displayname` (it is
|
|
212
|
+
commented out of its live-property list with a TODO), so a PROPFIND for it
|
|
213
|
+
answered `404 The displayname property was not found` and the client had nothing
|
|
214
|
+
to go on.
|
|
215
|
+
|
|
216
|
+
The default is the **key**, not `summary`: `displayname` is a name — RFC 4918
|
|
217
|
+
§15.2, "suitable for presentation to a user" — and the key is already the URL
|
|
218
|
+
segment and what the mount log prints. `summary` is a sentence and reads badly
|
|
219
|
+
as a folder label. `displayName` overrides the key where it is not presentable.
|
|
220
|
+
|
|
221
|
+
Only the mount root is named. Everything below it is a real file or directory
|
|
222
|
+
whose name the client reads from the path.
|
|
223
|
+
|
|
152
224
|
## When a client refuses and the server is right
|
|
153
225
|
|
|
154
226
|
WebDAV clients cache their **discovery** verdict — not the file listing, the
|
package/index.js
CHANGED
|
@@ -4,6 +4,8 @@ import { registerRoute, resolveAuth, reachabilityOf, registerJunk, registerCapab
|
|
|
4
4
|
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
|
+
import { withDisplayName } from './lib/display-name.js'
|
|
8
|
+
import { rootListing } from './lib/root-listing.js'
|
|
7
9
|
|
|
8
10
|
export { MikserAuthenticator, withStagedWrites, stageWrites }
|
|
9
11
|
|
|
@@ -43,6 +45,9 @@ export const writeCapability = (name) => `drive:${name}:write`
|
|
|
43
45
|
* content: { folder: 'documents' },
|
|
44
46
|
* media: { folder: 'files/media' },
|
|
45
47
|
* data: { folder: 'data', readOnly: true },
|
|
48
|
+
* Reports: { folder: 'reports' }, // shows as "Reports"
|
|
49
|
+
* q3: { folder: 'q3', displayName: 'Q3 reports' },
|
|
50
|
+
* share: { folder: 'share', host: 'drive.example.com' },
|
|
46
51
|
* },
|
|
47
52
|
* auth: identity,
|
|
48
53
|
* })
|
|
@@ -63,6 +68,19 @@ export function drive(options = {}) {
|
|
|
63
68
|
const {
|
|
64
69
|
base = '/drive',
|
|
65
70
|
endpoints = {},
|
|
71
|
+
// A domain of its own. On it, `/` is the drive: a collection listing
|
|
72
|
+
// the endpoints the caller may read, with each endpoint mounted
|
|
73
|
+
// beneath it at `/<name>`.
|
|
74
|
+
//
|
|
75
|
+
// The Microsoft WebDAV redirector establishes its session against the
|
|
76
|
+
// SERVER ROOT before it touches the path, so a share at
|
|
77
|
+
// `<base>/<name>` on a site whose `/` is a static site is one Explorer
|
|
78
|
+
// may never reach. Given a host, `\\drive.example.com@SSL\` is the
|
|
79
|
+
// mount and the public site is untouched.
|
|
80
|
+
//
|
|
81
|
+
// The `<base>/<name>` surface is unchanged and still answers on every
|
|
82
|
+
// host — this adds a way in, it does not move the old one.
|
|
83
|
+
host,
|
|
66
84
|
auth,
|
|
67
85
|
realm = 'mikser',
|
|
68
86
|
// Nephele defaults both to 'meta-files', which writes sidecars INTO
|
|
@@ -120,6 +138,9 @@ export function drive(options = {}) {
|
|
|
120
138
|
'terminate TLS in front of mikser.', runtime.options.url)
|
|
121
139
|
}
|
|
122
140
|
|
|
141
|
+
// What the drive's own root will list, if it has a host.
|
|
142
|
+
const rootEntries = []
|
|
143
|
+
|
|
123
144
|
for (const [name, ep] of Object.entries(endpoints)) {
|
|
124
145
|
if (!ep.folder) {
|
|
125
146
|
throw new Error(`drive: endpoint ${JSON.stringify(name)} declares no folder`)
|
|
@@ -206,20 +227,40 @@ export function drive(options = {}) {
|
|
|
206
227
|
// property is found and bound on either path.
|
|
207
228
|
fsAdapter.getOptionsResponseCacheControl = async () => 'no-cache'
|
|
208
229
|
|
|
209
|
-
|
|
230
|
+
const mountServer = nepheleServer({
|
|
210
231
|
// Writes are staged to a sibling temp file and renamed.
|
|
211
232
|
// The adapter writes straight to the destination with
|
|
212
233
|
// open(path,'w'), which truncates immediately — so the
|
|
213
234
|
// watcher can import a half-written file, and an
|
|
214
235
|
// interrupted overwrite leaves the ORIGINAL destroyed.
|
|
215
236
|
// Both measured; see lib/staged-writes.js.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
237
|
+
// The mount answers to its own name.
|
|
238
|
+
//
|
|
239
|
+
// `displayname` is what a client labels the folder with,
|
|
240
|
+
// and the adapter does not implement it — so the label was
|
|
241
|
+
// whatever each client derived from the URL, which is not
|
|
242
|
+
// controllable: Express matches routes case-insensitively,
|
|
243
|
+
// so an endpoint renamed to `SkinCheck` went on reading
|
|
244
|
+
// `skincheck` in Explorer, from a casing the client had
|
|
245
|
+
// cached.
|
|
246
|
+
//
|
|
247
|
+
// Defaults to the endpoint KEY rather than `summary`.
|
|
248
|
+
// displayname is a NAME — RFC 4918 §15.2, "suitable for
|
|
249
|
+
// presentation to a user" — and the key is already the URL
|
|
250
|
+
// segment and the mount log label, so nothing new has to
|
|
251
|
+
// be written down. `summary` is a sentence, which reads
|
|
252
|
+
// badly as a folder label. `displayName` overrides it for
|
|
253
|
+
// a key that is not presentable.
|
|
254
|
+
adapter: withDisplayName(
|
|
255
|
+
(ep.atomicWrites === false)
|
|
256
|
+
? fsAdapter
|
|
257
|
+
: withStagedWrites(fsAdapter, {
|
|
258
|
+
onFailure: (err, file) => logger.warn(
|
|
259
|
+
'drive: upload of %s failed, original left intact — %s',
|
|
260
|
+
path.basename(file), err.message),
|
|
261
|
+
}),
|
|
262
|
+
ep.displayName ?? name,
|
|
263
|
+
),
|
|
223
264
|
authenticator,
|
|
224
265
|
// `readOnly: true` is a hard cap — "nobody writes here",
|
|
225
266
|
// which is a different statement from "you may not write
|
|
@@ -227,7 +268,24 @@ export function drive(options = {}) {
|
|
|
227
268
|
// in the authenticator, because Nephele resolves plugins
|
|
228
269
|
// BEFORE it authenticates and the hook cannot see the user.
|
|
229
270
|
plugins: readOnly ? [new ReadOnlyPlugin()] : [],
|
|
230
|
-
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
app.use(mountPath, mountServer)
|
|
274
|
+
|
|
275
|
+
// The same server, reached from the drive's own domain.
|
|
276
|
+
// Scoped to the Host header, and that is not optional: mounted
|
|
277
|
+
// at `/<name>` for every host, it would shadow a real page.
|
|
278
|
+
if (host) {
|
|
279
|
+
app.use(`/${name}`, (req, res, next) =>
|
|
280
|
+
(req.hostname === host ? mountServer(req, res, next) : next()))
|
|
281
|
+
// Named for the client with the same rule the mount uses,
|
|
282
|
+
// so the listing and the folder agree.
|
|
283
|
+
rootEntries.push({
|
|
284
|
+
name,
|
|
285
|
+
displayName: ep.displayName ?? name,
|
|
286
|
+
capability: readCapability(name),
|
|
287
|
+
})
|
|
288
|
+
}
|
|
231
289
|
|
|
232
290
|
registerRoute({
|
|
233
291
|
path: mountPath,
|
|
@@ -295,6 +353,37 @@ export function drive(options = {}) {
|
|
|
295
353
|
})
|
|
296
354
|
}
|
|
297
355
|
|
|
356
|
+
// The drive's own root, last: it answers only for `/`, and only
|
|
357
|
+
// on its host, so it has to sit behind the endpoint mounts rather
|
|
358
|
+
// than in front of them.
|
|
359
|
+
if (host) {
|
|
360
|
+
app.use((req, res, next) =>
|
|
361
|
+
(req.hostname === host
|
|
362
|
+
? rootListing({
|
|
363
|
+
entries: rootEntries,
|
|
364
|
+
rootName: options.displayName ?? host,
|
|
365
|
+
auth: {
|
|
366
|
+
verifier: resolveAuth(auth),
|
|
367
|
+
allowRemote: true,
|
|
368
|
+
trustLoopback: !auth,
|
|
369
|
+
realm,
|
|
370
|
+
},
|
|
371
|
+
logger,
|
|
372
|
+
})(req, res, next)
|
|
373
|
+
: next()))
|
|
374
|
+
|
|
375
|
+
registerRoute({
|
|
376
|
+
path: '/',
|
|
377
|
+
host,
|
|
378
|
+
plugin: 'drive',
|
|
379
|
+
reachability: reachabilityOf({ auth: resolveAuth(auth), allowRemote: true }),
|
|
380
|
+
methods: ['OPTIONS', 'PROPFIND'],
|
|
381
|
+
cors: false,
|
|
382
|
+
label: 'WebDAV root',
|
|
383
|
+
detail: `(${names.length} endpoint${names.length === 1 ? '' : 's'})`,
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
|
|
298
387
|
logger.info('WebDAV mounted at %s (%s)', base, names.join(', '))
|
|
299
388
|
|
|
300
389
|
// File operations over MCP, for an agent with no route to the
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The mount's own name, served as `displayname` on its root collection.
|
|
2
|
+
//
|
|
3
|
+
// A WebDAV client shows a folder label. WebDAV has a property for it —
|
|
4
|
+
// RFC 4918 §15.2, "a name for the resource suitable for presentation to a
|
|
5
|
+
// user" — and @nephele/adapter-file-system does not implement it: the name is
|
|
6
|
+
// commented out of its live-property list with a TODO, so a PROPFIND for it
|
|
7
|
+
// answers 404 and the client falls back to guessing from the URL.
|
|
8
|
+
//
|
|
9
|
+
// Guessing from the URL is not controllable. Express matches routes
|
|
10
|
+
// case-insensitively, so `/drive/SkinCheck`, `/drive/skincheck` and
|
|
11
|
+
// `/drive/SKINCHECK` all reach the same mount, and the casing a client ends up
|
|
12
|
+
// displaying is whichever one it happened to ask for first. An endpoint
|
|
13
|
+
// renamed to `SkinCheck` went on reading `skincheck` in Explorer.
|
|
14
|
+
//
|
|
15
|
+
// The name is already written down — it is the ENDPOINTS key, the URL segment
|
|
16
|
+
// and the mount log label — so the site can just answer the question instead.
|
|
17
|
+
//
|
|
18
|
+
// Applied to the root collection only. Every other resource is a real file or
|
|
19
|
+
// directory whose name the client reads from the path, and which the author
|
|
20
|
+
// did not choose in mikser's config.
|
|
21
|
+
|
|
22
|
+
// The root of a mount. `Resource.path` is relative to the adapter root with a
|
|
23
|
+
// trailing separator stripped, so the mount's own collection is the empty one.
|
|
24
|
+
const isMountRoot = (resource) => resource?.path === ''
|
|
25
|
+
|
|
26
|
+
// A Properties facade that answers one more question than the one it wraps.
|
|
27
|
+
//
|
|
28
|
+
// PROPFIND reaches properties through the BY-USER variants — listByUser,
|
|
29
|
+
// getAllByUser, getByUser — and the adapter's versions delegate to the plain
|
|
30
|
+
// ones on `this`. A Proxy binds methods to the TARGET, so wrapping only the
|
|
31
|
+
// plain three would leave PROPFIND reading straight past this. Both sets are
|
|
32
|
+
// wrapped, which is why the property actually appears.
|
|
33
|
+
function withDisplayNameProperty(properties, displayName) {
|
|
34
|
+
const answer = (name, fallback) =>
|
|
35
|
+
name === 'displayname' ? Promise.resolve(displayName) : fallback()
|
|
36
|
+
const including = async (listing) => {
|
|
37
|
+
const names = await listing()
|
|
38
|
+
return names.includes('displayname') ? names : ['displayname', ...names]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return new Proxy(properties, {
|
|
42
|
+
get(target, prop, receiver) {
|
|
43
|
+
switch (prop) {
|
|
44
|
+
case 'get': return (name) => answer(name, () => target.get(name))
|
|
45
|
+
case 'getByUser': return (name, user) => answer(name, () => target.getByUser(name, user))
|
|
46
|
+
case 'list': return () => including(() => target.list())
|
|
47
|
+
case 'listByUser': return (user) => including(() => target.listByUser(user))
|
|
48
|
+
case 'listLive': return () => including(() => target.listLive())
|
|
49
|
+
case 'listLiveByUser': return (user) => including(() => target.listLiveByUser(user))
|
|
50
|
+
case 'getAll': return async () => ({ ...(await target.getAll()), displayname: displayName })
|
|
51
|
+
case 'getAllByUser': return async (user) => ({ ...(await target.getAllByUser(user)), displayname: displayName })
|
|
52
|
+
}
|
|
53
|
+
const value = Reflect.get(target, prop, receiver)
|
|
54
|
+
return typeof value === 'function' ? value.bind(target) : value
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function withDisplayNameResource(resource, displayName) {
|
|
60
|
+
return new Proxy(resource, {
|
|
61
|
+
get(target, prop, receiver) {
|
|
62
|
+
if (prop === 'getProperties') {
|
|
63
|
+
return async () => withDisplayNameProperty(await target.getProperties(), displayName)
|
|
64
|
+
}
|
|
65
|
+
const value = Reflect.get(target, prop, receiver)
|
|
66
|
+
return typeof value === 'function' ? value.bind(target) : value
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Decorate an adapter so its root collection reports `displayName`.
|
|
73
|
+
*
|
|
74
|
+
* Same shape as withStagedWrites, and composes with it: both wrap the three
|
|
75
|
+
* methods that hand back a resource, and each passes everything else through.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} adapter a nephele adapter
|
|
78
|
+
* @param {string} displayName what a client should call this mount
|
|
79
|
+
*/
|
|
80
|
+
export function withDisplayName(adapter, displayName) {
|
|
81
|
+
if (!displayName) return adapter
|
|
82
|
+
const wrap = (method) => async (...args) => {
|
|
83
|
+
const resource = await adapter[method](...args)
|
|
84
|
+
return isMountRoot(resource) ? withDisplayNameResource(resource, displayName) : resource
|
|
85
|
+
}
|
|
86
|
+
return new Proxy(adapter, {
|
|
87
|
+
get(target, prop, receiver) {
|
|
88
|
+
if (prop === 'getResource' || prop === 'newResource' || prop === 'newCollection') {
|
|
89
|
+
return wrap(prop)
|
|
90
|
+
}
|
|
91
|
+
const value = Reflect.get(target, prop, receiver)
|
|
92
|
+
return typeof value === 'function' ? value.bind(target) : value
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
}
|
|
@@ -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.0",
|
|
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",
|
|
@@ -51,6 +51,19 @@ before(async () => {
|
|
|
51
51
|
})
|
|
52
52
|
|
|
53
53
|
after(async () => {
|
|
54
|
+
// Destroy the sockets before closing, or the close never settles.
|
|
55
|
+
//
|
|
56
|
+
// `server.close()` waits for every connection to end, and these suites
|
|
57
|
+
// leave some that never will: fetch() pools keep-alives, and the
|
|
58
|
+
// interrupted-upload cases abort a request mid-body on purpose. The file's
|
|
59
|
+
// tests would all pass and the FILE would hang — "Promise resolution is
|
|
60
|
+
// still pending but the event loop has already resolved" — which in CI is
|
|
61
|
+
// a job that stops reporting and, before the 15-minute cap, ran until
|
|
62
|
+
// GitHub's own. It blocked two of today's releases.
|
|
63
|
+
//
|
|
64
|
+
// Reproducible with `--test-concurrency=2` (a CI runner's shape; a 16-core
|
|
65
|
+
// box hides it), and gone with this.
|
|
66
|
+
server?.closeAllConnections?.()
|
|
54
67
|
await new Promise(r => server?.close(r))
|
|
55
68
|
await rm(dir, { recursive: true, force: true })
|
|
56
69
|
})
|
package/test/derived.test.js
CHANGED
|
@@ -156,7 +156,27 @@ before(async (t) => {
|
|
|
156
156
|
})
|
|
157
157
|
|
|
158
158
|
after(async () => {
|
|
159
|
-
|
|
159
|
+
// WAIT for it to die, and escalate. `kill()` only sends the signal; the
|
|
160
|
+
// child's stdout/stderr are pipes we hold, so until it actually exits
|
|
161
|
+
// those handles keep THIS process alive and the run never ends. Every
|
|
162
|
+
// test passes and then node sits there — on CI that was fifteen minutes
|
|
163
|
+
// of nothing, ended by the job timeout and "Terminate orphan process".
|
|
164
|
+
//
|
|
165
|
+
// mikser handles SIGTERM itself (instance.js: close(); process.exit(0)),
|
|
166
|
+
// so the graceful path is the normal one; SIGKILL is the floor for when
|
|
167
|
+
// close() does not come back.
|
|
168
|
+
if (server && server.exitCode === null && server.signalCode === null) {
|
|
169
|
+
const exited = new Promise(resolve => server.once('exit', resolve))
|
|
170
|
+
server.kill()
|
|
171
|
+
const inTime = await Promise.race([
|
|
172
|
+
exited.then(() => true),
|
|
173
|
+
new Promise(resolve => setTimeout(() => resolve(false), 5000)),
|
|
174
|
+
])
|
|
175
|
+
if (!inTime) {
|
|
176
|
+
server.kill('SIGKILL')
|
|
177
|
+
await exited
|
|
178
|
+
}
|
|
179
|
+
}
|
|
160
180
|
if (dir) await rm(dir, { recursive: true, force: true })
|
|
161
181
|
})
|
|
162
182
|
|
package/test/drive.test.js
CHANGED
|
@@ -77,6 +77,19 @@ before(async () => {
|
|
|
77
77
|
})
|
|
78
78
|
|
|
79
79
|
after(async () => {
|
|
80
|
+
// Destroy the sockets before closing, or the close never settles.
|
|
81
|
+
//
|
|
82
|
+
// `server.close()` waits for every connection to end, and these suites
|
|
83
|
+
// leave some that never will: fetch() pools keep-alives, and the
|
|
84
|
+
// interrupted-upload cases abort a request mid-body on purpose. The file's
|
|
85
|
+
// tests would all pass and the FILE would hang — "Promise resolution is
|
|
86
|
+
// still pending but the event loop has already resolved" — which in CI is
|
|
87
|
+
// a job that stops reporting and, before the 15-minute cap, ran until
|
|
88
|
+
// GitHub's own. It blocked two of today's releases.
|
|
89
|
+
//
|
|
90
|
+
// Reproducible with `--test-concurrency=2` (a CI runner's shape; a 16-core
|
|
91
|
+
// box hides it), and gone with this.
|
|
92
|
+
server?.closeAllConnections?.()
|
|
80
93
|
await new Promise(r => server?.close(r))
|
|
81
94
|
await rm(dir, { recursive: true, force: true })
|
|
82
95
|
})
|
package/test/protocol.test.js
CHANGED
|
@@ -56,6 +56,19 @@ before(async () => {
|
|
|
56
56
|
})
|
|
57
57
|
|
|
58
58
|
after(async () => {
|
|
59
|
+
// Destroy the sockets before closing, or the close never settles.
|
|
60
|
+
//
|
|
61
|
+
// `server.close()` waits for every connection to end, and these suites
|
|
62
|
+
// leave some that never will: fetch() pools keep-alives, and the
|
|
63
|
+
// interrupted-upload cases abort a request mid-body on purpose. The file's
|
|
64
|
+
// tests would all pass and the FILE would hang — "Promise resolution is
|
|
65
|
+
// still pending but the event loop has already resolved" — which in CI is
|
|
66
|
+
// a job that stops reporting and, before the 15-minute cap, ran until
|
|
67
|
+
// GitHub's own. It blocked two of today's releases.
|
|
68
|
+
//
|
|
69
|
+
// Reproducible with `--test-concurrency=2` (a CI runner's shape; a 16-core
|
|
70
|
+
// box hides it), and gone with this.
|
|
71
|
+
server?.closeAllConnections?.()
|
|
59
72
|
await new Promise(r => server?.close(r))
|
|
60
73
|
await rm(dir, { recursive: true, force: true })
|
|
61
74
|
})
|
|
@@ -263,3 +276,58 @@ describe('known nephele non-compliance, pinned', () => {
|
|
|
263
276
|
assert.equal(res.status, 500, 'if this becomes 400, nephele fixed it')
|
|
264
277
|
})
|
|
265
278
|
})
|
|
279
|
+
|
|
280
|
+
describe('the mount answers to its own name', () => {
|
|
281
|
+
// A client labels the folder from `displayname`, and
|
|
282
|
+
// @nephele/adapter-file-system does not implement it — the name is
|
|
283
|
+
// commented out of its live-property list with a TODO — so a PROPFIND for
|
|
284
|
+
// it answered 404 and the client fell back to guessing from the URL.
|
|
285
|
+
//
|
|
286
|
+
// Guessing is not controllable: Express matches routes case-insensitively,
|
|
287
|
+
// so `/drive/SkinCheck` and `/drive/skincheck` reach the same mount and the
|
|
288
|
+
// casing shown is whichever the client asked for first. An endpoint renamed
|
|
289
|
+
// to `SkinCheck` went on reading `skincheck` in Explorer.
|
|
290
|
+
const propfind = (ep, body) => fetch(`http://127.0.0.1:${port}/drive/${ep}/`, {
|
|
291
|
+
method: 'PROPFIND',
|
|
292
|
+
headers: { authorization: AUTH, 'content-type': 'text/xml', depth: '0' },
|
|
293
|
+
body,
|
|
294
|
+
})
|
|
295
|
+
const NAMED = '<propfind xmlns="DAV:"><prop><displayname/></prop></propfind>'
|
|
296
|
+
const ALLPROP = '<propfind xmlns="DAV:"><allprop/></propfind>'
|
|
297
|
+
|
|
298
|
+
it('reports the endpoint key as displayname, asked for by name', async () => {
|
|
299
|
+
const res = await propfind('emulated', NAMED)
|
|
300
|
+
const xml = await res.text()
|
|
301
|
+
assert.equal(res.status, 207, xml)
|
|
302
|
+
assert.match(xml, /<[^>]*displayname[^>]*>emulated</,
|
|
303
|
+
`displayname must carry the endpoint key — got:\n${xml}`)
|
|
304
|
+
assert.doesNotMatch(xml, /displayname property was not found/, xml)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('includes it in allprop, which is how a client usually asks', async () => {
|
|
308
|
+
// PROPFIND reaches properties through the BY-USER variants, and the
|
|
309
|
+
// adapter's delegate to the plain ones on `this` — so a decorator that
|
|
310
|
+
// wrapped only the plain three would be read straight past. This is the
|
|
311
|
+
// case that catches that.
|
|
312
|
+
const xml = await (await propfind('emulated', ALLPROP)).text()
|
|
313
|
+
assert.match(xml, /<[^>]*displayname[^>]*>emulated</, `allprop must include it — got:\n${xml}`)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('names each endpoint separately, since each is its own mount', async () => {
|
|
317
|
+
const xml = await (await propfind('readonly', NAMED)).text()
|
|
318
|
+
assert.match(xml, /<[^>]*displayname[^>]*>readonly</, xml)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('leaves the files inside it alone', async () => {
|
|
322
|
+
// Only the mount ROOT is named in config. Everything below is a real
|
|
323
|
+
// file whose name the client reads from the path.
|
|
324
|
+
const res = await fetch(`http://127.0.0.1:${port}/drive/emulated/page.md`, {
|
|
325
|
+
method: 'PROPFIND',
|
|
326
|
+
headers: { authorization: AUTH, 'content-type': 'text/xml', depth: '0' },
|
|
327
|
+
body: NAMED,
|
|
328
|
+
})
|
|
329
|
+
const xml = await res.text()
|
|
330
|
+
assert.doesNotMatch(xml, /<[^>]*displayname[^>]*>emulated</,
|
|
331
|
+
`a file must not inherit the mount's name — got:\n${xml}`)
|
|
332
|
+
})
|
|
333
|
+
})
|
package/test/real-client.test.js
CHANGED
|
@@ -67,6 +67,19 @@ before(async () => {
|
|
|
67
67
|
})
|
|
68
68
|
|
|
69
69
|
after(async () => {
|
|
70
|
+
// Destroy the sockets before closing, or the close never settles.
|
|
71
|
+
//
|
|
72
|
+
// `server.close()` waits for every connection to end, and these suites
|
|
73
|
+
// leave some that never will: fetch() pools keep-alives, and the
|
|
74
|
+
// interrupted-upload cases abort a request mid-body on purpose. The file's
|
|
75
|
+
// tests would all pass and the FILE would hang — "Promise resolution is
|
|
76
|
+
// still pending but the event loop has already resolved" — which in CI is
|
|
77
|
+
// a job that stops reporting and, before the 15-minute cap, ran until
|
|
78
|
+
// GitHub's own. It blocked two of today's releases.
|
|
79
|
+
//
|
|
80
|
+
// Reproducible with `--test-concurrency=2` (a CI runner's shape; a 16-core
|
|
81
|
+
// box hides it), and gone with this.
|
|
82
|
+
server?.closeAllConnections?.()
|
|
70
83
|
await new Promise(r => server?.close(r))
|
|
71
84
|
await rm(dir, { recursive: true, force: true })
|
|
72
85
|
})
|
|
@@ -0,0 +1,181 @@
|
|
|
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('the drive root on its own host', () => {
|
|
117
|
+
it('announces itself as WebDAV, which is what the redirector asks first', async () => {
|
|
118
|
+
// Answered without credentials on purpose: this is discovery, and it
|
|
119
|
+
// is what the redirector asks BEFORE it has anything to offer. It
|
|
120
|
+
// discloses that a DAV server is here and nothing about what is in it.
|
|
121
|
+
const res = await request('OPTIONS', '/')
|
|
122
|
+
assert.equal(res.status, 200)
|
|
123
|
+
assert.match(res.headers.dav ?? '', /\b1\b/, 'the root must claim class 1')
|
|
124
|
+
assert.match(res.headers.allow ?? '', /PROPFIND/)
|
|
125
|
+
assert.equal(res.headers['cache-control'], 'no-cache',
|
|
126
|
+
'a discovery answer that can be cached is one a client keeps refusing')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('challenges an anonymous listing, so a DAV client prompts', async () => {
|
|
130
|
+
const res = await propfind()
|
|
131
|
+
assert.equal(res.status, 401)
|
|
132
|
+
assert.match(res.headers['www-authenticate'] ?? '', /^Basic /,
|
|
133
|
+
'Basic is the only scheme Explorer and Finder speak')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('lists only the endpoints the user may read', async () => {
|
|
137
|
+
const xml = (await propfind({ headers: { authorization: as('alice') } })).body
|
|
138
|
+
assert.match(xml, /<D:href>\/SkinCheck\/<\/D:href>/, xml)
|
|
139
|
+
assert.doesNotMatch(xml, /Reports/, 'bob\'s endpoint is not alice\'s to see')
|
|
140
|
+
assert.doesNotMatch(xml, /Media/, 'nor one nobody granted her')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('shows a different user a different drive', async () => {
|
|
144
|
+
const xml = (await propfind({ headers: { authorization: as('bob') } })).body
|
|
145
|
+
assert.match(xml, /<D:href>\/Reports\/<\/D:href>/, xml)
|
|
146
|
+
assert.doesNotMatch(xml, /SkinCheck/, xml)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('gives a wildcard holder everything, under its display name', async () => {
|
|
150
|
+
const xml = (await propfind({ headers: { authorization: as('carol') } })).body
|
|
151
|
+
for (const name of ['SkinCheck', 'Reports', 'Media']) {
|
|
152
|
+
assert.match(xml, new RegExp(`<D:href>/${name}/</D:href>`), `${name} missing:\n${xml}`)
|
|
153
|
+
}
|
|
154
|
+
assert.match(xml, /<D:displayname>Media library<\/D:displayname>/,
|
|
155
|
+
'the listing uses the same name the mount reports')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('describes only itself at Depth 0', async () => {
|
|
159
|
+
const xml = (await propfind({ headers: { authorization: as('carol'), depth: '0' } })).body
|
|
160
|
+
assert.match(xml, /<D:href>\/<\/D:href>/)
|
|
161
|
+
assert.doesNotMatch(xml, /SkinCheck/, 'Depth 0 is the collection, not its children')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('leaves every other host alone', async () => {
|
|
165
|
+
// The whole safety of mounting at `/`. Without the Host gate a drive
|
|
166
|
+
// shadows every page on the site.
|
|
167
|
+
for (const method of ['OPTIONS', 'PROPFIND']) {
|
|
168
|
+
const res = await request(method, '/', { host: 'www.example.test', headers: { depth: '1' } })
|
|
169
|
+
assert.notEqual(res.status, 207, `${method} answered for the wrong host`)
|
|
170
|
+
assert.equal(res.headers.dav, undefined, `${method} advertised DAV to the wrong host`)
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('serves the endpoint itself beneath the root', async () => {
|
|
175
|
+
const res = await request('PROPFIND', '/SkinCheck/', {
|
|
176
|
+
headers: { depth: '1', authorization: as('alice') },
|
|
177
|
+
})
|
|
178
|
+
assert.equal(res.status, 207, res.body)
|
|
179
|
+
assert.match(res.body, /page\.md/, 'entering an endpoint reaches its real files')
|
|
180
|
+
})
|
|
181
|
+
})
|