mikser-io-drive 11.1.0 → 11.2.2

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.
@@ -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,37 @@ 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
+ ## What the folder is called
153
+
154
+ The endpoint key is the name, and the mount reports it as `displayname`:
155
+
156
+ ```js
157
+ drive({
158
+ endpoints: {
159
+ SkinCheck: { folder: 'skincheck' }, // shows as "SkinCheck"
160
+ reports: { folder: 'data', displayName: 'Q3 reports' },
161
+ },
162
+ })
163
+ ```
164
+
165
+ Without it the label is whatever the client derives from the URL, and that is
166
+ not controllable: Express matches routes case-insensitively, so
167
+ `/drive/SkinCheck` and `/drive/skincheck` reach the same mount and the casing
168
+ shown is whichever the client asked for first — an endpoint renamed to
169
+ `SkinCheck` kept reading `skincheck` in Explorer, and remapping did not change
170
+ it. `@nephele/adapter-file-system` does not implement `displayname` (it is
171
+ commented out of its live-property list with a TODO), so a PROPFIND for it
172
+ answered `404 The displayname property was not found` and the client had nothing
173
+ to go on.
174
+
175
+ The default is the **key**, not `summary`: `displayname` is a name — RFC 4918
176
+ §15.2, "suitable for presentation to a user" — and the key is already the URL
177
+ segment and what the mount log prints. `summary` is a sentence and reads badly
178
+ as a folder label. `displayName` overrides the key where it is not presentable.
179
+
180
+ Only the mount root is named. Everything below it is a real file or directory
181
+ whose name the client reads from the path.
182
+
152
183
  ## When a client refuses and the server is right
153
184
 
154
185
  WebDAV clients cache their **discovery** verdict — not the file listing, the
package/index.js CHANGED
@@ -4,6 +4,7 @@ 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'
7
8
 
8
9
  export { MikserAuthenticator, withStagedWrites, stageWrites }
9
10
 
@@ -43,6 +44,8 @@ export const writeCapability = (name) => `drive:${name}:write`
43
44
  * content: { folder: 'documents' },
44
45
  * media: { folder: 'files/media' },
45
46
  * data: { folder: 'data', readOnly: true },
47
+ * Reports: { folder: 'reports' }, // shows as "Reports"
48
+ * q3: { folder: 'q3', displayName: 'Q3 reports' },
46
49
  * },
47
50
  * auth: identity,
48
51
  * })
@@ -213,13 +216,33 @@ export function drive(options = {}) {
213
216
  // watcher can import a half-written file, and an
214
217
  // interrupted overwrite leaves the ORIGINAL destroyed.
215
218
  // Both measured; see lib/staged-writes.js.
216
- adapter: (ep.atomicWrites === false)
217
- ? fsAdapter
218
- : withStagedWrites(fsAdapter, {
219
- onFailure: (err, file) => logger.warn(
220
- 'drive: upload of %s failed, original left intact — %s',
221
- path.basename(file), err.message),
222
- }),
219
+ // The mount answers to its own name.
220
+ //
221
+ // `displayname` is what a client labels the folder with,
222
+ // and the adapter does not implement it — so the label was
223
+ // whatever each client derived from the URL, which is not
224
+ // controllable: Express matches routes case-insensitively,
225
+ // so an endpoint renamed to `SkinCheck` went on reading
226
+ // `skincheck` in Explorer, from a casing the client had
227
+ // cached.
228
+ //
229
+ // Defaults to the endpoint KEY rather than `summary`.
230
+ // displayname is a NAME — RFC 4918 §15.2, "suitable for
231
+ // presentation to a user" — and the key is already the URL
232
+ // segment and the mount log label, so nothing new has to
233
+ // be written down. `summary` is a sentence, which reads
234
+ // badly as a folder label. `displayName` overrides it for
235
+ // a key that is not presentable.
236
+ adapter: withDisplayName(
237
+ (ep.atomicWrites === false)
238
+ ? fsAdapter
239
+ : withStagedWrites(fsAdapter, {
240
+ onFailure: (err, file) => logger.warn(
241
+ 'drive: upload of %s failed, original left intact — %s',
242
+ path.basename(file), err.message),
243
+ }),
244
+ ep.displayName ?? name,
245
+ ),
223
246
  authenticator,
224
247
  // `readOnly: true` is a hard cap — "nobody writes here",
225
248
  // which is a different statement from "you may not write
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-drive",
3
- "version": "11.1.0",
3
+ "version": "11.2.2",
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",
@@ -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
  })
@@ -156,7 +156,27 @@ before(async (t) => {
156
156
  })
157
157
 
158
158
  after(async () => {
159
- server?.kill()
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
 
@@ -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
  })
@@ -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
+ })
@@ -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
  })