@summerscar/dsh-mnemon-sync 0.1.7 → 0.1.9
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/index.js +83 -17
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -40,10 +40,10 @@ const API_PREFIX = '/api/mnemon-sync'
|
|
|
40
40
|
const ARCHIVE_PREFIX = 'mnemon-sync-'
|
|
41
41
|
|
|
42
42
|
export const name = 'dsh-mnemon-sync'
|
|
43
|
-
// `
|
|
44
|
-
//
|
|
45
|
-
// its page there.
|
|
46
|
-
export const inject = ['webServer']
|
|
43
|
+
// `connection` gates every HTTP route below. `mnemonMemory` stays optional on
|
|
44
|
+
// purpose: the plugin still backs up on a profile where dsh-mnemon is absent
|
|
45
|
+
// or too old to accept Sources, it just cannot offer its page there.
|
|
46
|
+
export const inject = ['webServer', 'connection']
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
49
|
* `MNEMON_DATA_DIR` names the Mnemon home; the memory store itself lives in
|
|
@@ -201,10 +201,45 @@ export function apply(ctx, config = {}) {
|
|
|
201
201
|
// page use its own type instead of borrowing `memory-spaces`, which is the
|
|
202
202
|
// only way its tab can highlight independently.
|
|
203
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Admit only requests the Host's connection service vouches for.
|
|
206
|
+
*
|
|
207
|
+
* These routes read credentials, trigger uploads and can restore files onto
|
|
208
|
+
* disk, so an unauthenticated local request must never reach them. Follow
|
|
209
|
+
* the current `requestRejection(req)` contract (undefined = admit, or an
|
|
210
|
+
* HTTP status); older Hosts exposing only `authorizeIndex` still work.
|
|
211
|
+
* If neither is available, reject rather than falling back to Host/Origin
|
|
212
|
+
* or loopback checks — a missing guard is not a reason to open the route.
|
|
213
|
+
*/
|
|
214
|
+
const rejected = (req, res) => {
|
|
215
|
+
const connection = typeof ctx.get === 'function' ? ctx.get('connection') : undefined
|
|
216
|
+
if (connection === undefined || connection === null) {
|
|
217
|
+
res.statusCode = 401
|
|
218
|
+
res.end()
|
|
219
|
+
return true
|
|
220
|
+
}
|
|
221
|
+
if (typeof connection.requestRejection === 'function') {
|
|
222
|
+
const rejection = connection.requestRejection(req)
|
|
223
|
+
if (rejection === undefined) return false
|
|
224
|
+
res.statusCode = rejection
|
|
225
|
+
res.end()
|
|
226
|
+
return true
|
|
227
|
+
}
|
|
228
|
+
if (typeof connection.authorizeIndex === 'function') {
|
|
229
|
+
// `authorizeIndex` answers the response itself when it rejects; its
|
|
230
|
+
// boolean says which way it went.
|
|
231
|
+
return connection.authorizeIndex(req, res) === false
|
|
232
|
+
}
|
|
233
|
+
res.statusCode = 401
|
|
234
|
+
res.end()
|
|
235
|
+
return true
|
|
236
|
+
}
|
|
237
|
+
|
|
204
238
|
ctx.effect(() => ctx.webServer.register({
|
|
205
239
|
kind: 'prefix',
|
|
206
240
|
path: API_PREFIX,
|
|
207
241
|
handler: (req, res) => {
|
|
242
|
+
if (rejected(req, res)) return
|
|
208
243
|
void handle(req, res).catch((error) => {
|
|
209
244
|
sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
210
245
|
})
|
|
@@ -452,14 +487,21 @@ export function apply(ctx, config = {}) {
|
|
|
452
487
|
}))
|
|
453
488
|
const restorable = requested.filter((component) => present.includes(component))
|
|
454
489
|
|
|
455
|
-
// Plan first, so a dry run and a real run describe the same work.
|
|
490
|
+
// Plan first, so a dry run and a real run describe the same work. Entries
|
|
491
|
+
// whose path does not resolve into a component directory (a foreign file
|
|
492
|
+
// smuggled into the archive) are rejected outright rather than skipped:
|
|
493
|
+
// a listing that silently drops entries would hide what a restore does.
|
|
456
494
|
const plan = []
|
|
457
495
|
for (const component of restorable) {
|
|
458
496
|
for (const [relative, content] of files.entries()) {
|
|
459
497
|
if (relative === 'manifest.json') continue
|
|
460
498
|
const director = componentDirectoryOf(relative)
|
|
461
499
|
if (director !== component) continue
|
|
462
|
-
|
|
500
|
+
const destination = destinationFor(home, relative)
|
|
501
|
+
if (destination === undefined) {
|
|
502
|
+
throw new Error(`archive entry cannot be restored safely: ${relative}`)
|
|
503
|
+
}
|
|
504
|
+
plan.push({ component, relative, destination, bytes: content.length })
|
|
463
505
|
}
|
|
464
506
|
}
|
|
465
507
|
|
|
@@ -559,20 +601,44 @@ function componentDirectoryOf(relative) {
|
|
|
559
601
|
}
|
|
560
602
|
|
|
561
603
|
/**
|
|
562
|
-
*
|
|
604
|
+
* Archive payload directories that a restore may write, mapped to the live
|
|
605
|
+
* directories they land in. `state/` is deliberately absent: it holds this
|
|
606
|
+
* plugin's credentials and dsh-mnemon's machine identity, and no backup
|
|
607
|
+
* component backs it up.
|
|
608
|
+
*/
|
|
609
|
+
const RESTORE_DIRECTORIES = {
|
|
610
|
+
data: 'data',
|
|
611
|
+
documents: 'documents',
|
|
612
|
+
runtime: 'runtime',
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Where one archive path lands in the Mnemon home, or `undefined` when the
|
|
617
|
+
* path is not part of a restorable component.
|
|
563
618
|
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
619
|
+
* The check is deliberately stricter than "stays inside the home": a restore
|
|
620
|
+
* must only ever touch the three component directories (`data/`, `documents/`,
|
|
621
|
+
* `runtime/`). Everything else in the home — `state/` with this plugin's own
|
|
622
|
+
* credentials and dsh-mnemon's machine identity, `skills/`, the archive
|
|
623
|
+
* workroot — must be unreachable from an archive, because the archive is
|
|
624
|
+
* untrusted data. A zip entry like `payload/data/../state/mnemon-sync.json`
|
|
625
|
+
* resolves inside the home yet would overwrite the stored credentials, so the
|
|
626
|
+
* first path segment after `payload/` is matched exactly against the three
|
|
627
|
+
* component names and no `..` or nested separator survives.
|
|
568
628
|
*/
|
|
569
629
|
function destinationFor(home, relative) {
|
|
570
|
-
|
|
571
|
-
const
|
|
572
|
-
const
|
|
573
|
-
if (
|
|
574
|
-
|
|
575
|
-
|
|
630
|
+
if (!relative.startsWith('payload/')) return undefined
|
|
631
|
+
const stripped = relative.slice('payload/'.length)
|
|
632
|
+
const separator = stripped.indexOf('/')
|
|
633
|
+
if (separator <= 0) return undefined
|
|
634
|
+
const topDirectory = stripped.slice(0, separator)
|
|
635
|
+
const componentDirectory = RESTORE_DIRECTORIES[topDirectory]
|
|
636
|
+
if (componentDirectory === undefined) return undefined
|
|
637
|
+
const rest = stripped.slice(separator + 1)
|
|
638
|
+
if (rest === '' || rest.includes('\0') || rest.split('/').includes('..')) return undefined
|
|
639
|
+
const destination = resolve(home, componentDirectory, ...rest.split('/'))
|
|
640
|
+
const root = resolve(home, componentDirectory)
|
|
641
|
+
if (!destination.startsWith(`${root}/`)) return undefined
|
|
576
642
|
return destination
|
|
577
643
|
}
|
|
578
644
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@summerscar/dsh-mnemon-sync",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Back up and restore dsh-mnemon memory components (memory spaces, project documents, runtime memory) to S3-compatible storage or WebDAV.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|