@crombolo/transfer 0.13.1 → 0.14.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/package.json +3 -3
- package/src/import.ts +6 -1
- package/src/index.ts +1 -0
- package/src/load.ts +12 -0
- package/src/model.ts +16 -0
- package/src/sanitise.ts +121 -0
- package/src/write.ts +59 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/transfer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "Content transfer for crombolo: bundles of content moved between environments.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
32
|
-
"@crombolo/data": "0.
|
|
31
|
+
"@crombolo/core": "0.14.1",
|
|
32
|
+
"@crombolo/data": "0.14.1"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/import.ts
CHANGED
|
@@ -71,6 +71,11 @@ export interface ImportOptions extends CheckOptions {
|
|
|
71
71
|
label?: string | undefined
|
|
72
72
|
/** The npm package the bundle arrived in, recorded on the run. */
|
|
73
73
|
package?: { name: string; version: string } | undefined
|
|
74
|
+
/**
|
|
75
|
+
* A node's values become exactly the bundle's, rather than the bundle's laid
|
|
76
|
+
* over what is here: a whole-site replace (story 105).
|
|
77
|
+
*/
|
|
78
|
+
exact?: boolean
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
export interface ImportResult {
|
|
@@ -342,7 +347,7 @@ async function applyBundle(tx: Db, set: ContentSet, options: ImportOptions): Pro
|
|
|
342
347
|
? ((await templates.byAlias(node.template))?.key ?? current.componentKey)
|
|
343
348
|
: current.componentKey,
|
|
344
349
|
parentKey: current.parentKey,
|
|
345
|
-
values: overlay(current.values, taken),
|
|
350
|
+
values: overlay(options.exact ? [] : current.values, taken),
|
|
346
351
|
variants: keepNames
|
|
347
352
|
? current.variants.map((v) => ({
|
|
348
353
|
culture: v.culture,
|
package/src/index.ts
CHANGED
package/src/load.ts
CHANGED
|
@@ -257,6 +257,7 @@ function parseManifest(text: string, problems: BundleProblem[]): BundleManifest
|
|
|
257
257
|
schemaVersion: str(provenance.schemaVersion) ?? '',
|
|
258
258
|
schemaRevision: str(provenance.schemaRevision) ?? '',
|
|
259
259
|
schemaHash: nullableStr(provenance.schemaHash) ?? null,
|
|
260
|
+
...(str(provenance.cmsVersion) ? { cmsVersion: str(provenance.cmsVersion) as string } : {}),
|
|
260
261
|
},
|
|
261
262
|
selector: {
|
|
262
263
|
roots: keys(selector.roots),
|
|
@@ -313,9 +314,20 @@ function parseManifest(text: string, problems: BundleProblem[]): BundleManifest
|
|
|
313
314
|
: {}),
|
|
314
315
|
...(parseSealParameters(raw.seal) ? { seal: parseSealParameters(raw.seal) } : {}),
|
|
315
316
|
...(raw.content === 'sealed' || raw.content === 'omitted' ? { content: raw.content } : {}),
|
|
317
|
+
...(raw.scope === 'site' ? { scope: 'site' as const } : {}),
|
|
318
|
+
...(raw.media === 'omitted' ? { media: 'omitted' as const } : {}),
|
|
319
|
+
...(isRecord(raw.requires) ? { requires: parseRequires(raw.requires) } : {}),
|
|
316
320
|
}
|
|
317
321
|
}
|
|
318
322
|
|
|
323
|
+
/** Package names to versions, keeping only string pairs. */
|
|
324
|
+
function parseRequires(raw: Record<string, unknown>): Record<string, string> {
|
|
325
|
+
const out: Record<string, string> = {}
|
|
326
|
+
for (const [name, version] of Object.entries(raw))
|
|
327
|
+
if (typeof version === 'string') out[name] = version
|
|
328
|
+
return out
|
|
329
|
+
}
|
|
330
|
+
|
|
319
331
|
/**
|
|
320
332
|
* The declared sections, dropping any name this version does not know and any
|
|
321
333
|
* path that tries to climb out of the bundle — a manifest is a file that
|
package/src/model.ts
CHANGED
|
@@ -172,6 +172,8 @@ export interface BundleProvenance {
|
|
|
172
172
|
schemaRevision: string
|
|
173
173
|
schemaHash: string | null
|
|
174
174
|
siteName: string
|
|
175
|
+
/** The CMS version that wrote the bundle (story 104). Absent on bundles written before it. */
|
|
176
|
+
cmsVersion?: string
|
|
175
177
|
}
|
|
176
178
|
|
|
177
179
|
export interface BundleSelector {
|
|
@@ -228,6 +230,20 @@ export interface BundleManifest {
|
|
|
228
230
|
* Absent: in the open.
|
|
229
231
|
*/
|
|
230
232
|
content?: BundleContentMode
|
|
233
|
+
/**
|
|
234
|
+
* `site`: a whole site (story 104) — every node, the whole schema and every
|
|
235
|
+
* file — which an import replaces a site with rather than overlays. Absent: a
|
|
236
|
+
* selection, overlaid as always.
|
|
237
|
+
*/
|
|
238
|
+
scope?: 'site'
|
|
239
|
+
/** `omitted`: a whole-site file written without its media files (`--no-media`). */
|
|
240
|
+
media?: 'omitted'
|
|
241
|
+
/**
|
|
242
|
+
* The server bundles the site wires, by package name, at the versions that
|
|
243
|
+
* wrote it. An import refuses when the destination lacks one or has an older
|
|
244
|
+
* version, naming it.
|
|
245
|
+
*/
|
|
246
|
+
requires?: Record<string, string>
|
|
231
247
|
}
|
|
232
248
|
|
|
233
249
|
export type BundleContentMode = 'sealed' | 'omitted'
|
package/src/sanitise.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anonymising a bundle before it is written (story 104, reused by 100's clone):
|
|
3
|
+
* the structure travels, the people in it do not. It runs on what the export
|
|
4
|
+
* gathered and before anything is serialised, so an unsanitised copy never
|
|
5
|
+
* exists in the output.
|
|
6
|
+
*
|
|
7
|
+
* - Back-office users are removed; the destination makes one fresh local
|
|
8
|
+
* administrator. User groups stay, as structure.
|
|
9
|
+
* - Members stay as placeholders — type, groups and approval kept — with name,
|
|
10
|
+
* username and email replaced, the password and every property value gone.
|
|
11
|
+
* - Every email address in content, people and the dictionary is replaced, the
|
|
12
|
+
* same address by the same stand-in each time so references still agree.
|
|
13
|
+
*
|
|
14
|
+
* Tokens, sessions, external logins and form entries are never exported, so
|
|
15
|
+
* there is nothing of theirs to remove. Code and schema are left alone: an
|
|
16
|
+
* address in a template is the site's, not a person's.
|
|
17
|
+
*/
|
|
18
|
+
import type { BundleNode, BundleValue, ContentSet } from './model.ts'
|
|
19
|
+
import type { BundleMember, BundlePeople } from './people.ts'
|
|
20
|
+
|
|
21
|
+
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g
|
|
22
|
+
|
|
23
|
+
/** The domain stand-ins use: reserved, so nothing is ever delivered to it. */
|
|
24
|
+
export const ANONYMISED_DOMAIN = 'example.invalid'
|
|
25
|
+
|
|
26
|
+
export interface SanitiseInput {
|
|
27
|
+
set: ContentSet
|
|
28
|
+
people?: BundlePeople
|
|
29
|
+
/** The dictionary `.udt`, whose translations may hold addresses. */
|
|
30
|
+
dictionary?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SanitiseResult {
|
|
34
|
+
set: ContentSet
|
|
35
|
+
people: BundlePeople | undefined
|
|
36
|
+
dictionary: string | undefined
|
|
37
|
+
/** How many distinct email addresses were replaced. */
|
|
38
|
+
emails: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function sanitise(input: SanitiseInput): SanitiseResult {
|
|
42
|
+
const standIns = new Map<string, string>()
|
|
43
|
+
const standInFor = (address: string): string => {
|
|
44
|
+
const known = address.toLowerCase()
|
|
45
|
+
let replacement = standIns.get(known)
|
|
46
|
+
if (!replacement) {
|
|
47
|
+
replacement = `person-${standIns.size + 1}@${ANONYMISED_DOMAIN}`
|
|
48
|
+
standIns.set(known, replacement)
|
|
49
|
+
}
|
|
50
|
+
return replacement
|
|
51
|
+
}
|
|
52
|
+
const scrub = (text: string) =>
|
|
53
|
+
text.replace(EMAIL, (address) =>
|
|
54
|
+
address.toLowerCase().endsWith(`@${ANONYMISED_DOMAIN}`) ? address : standInFor(address),
|
|
55
|
+
)
|
|
56
|
+
const deep = (value: unknown): unknown => {
|
|
57
|
+
if (typeof value === 'string') return scrub(value)
|
|
58
|
+
if (Array.isArray(value)) return value.map(deep)
|
|
59
|
+
if (value && typeof value === 'object')
|
|
60
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, deep(v)]))
|
|
61
|
+
return value
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const usernames = new Map<string, string>()
|
|
65
|
+
const node = (n: BundleNode): BundleNode => ({
|
|
66
|
+
...n,
|
|
67
|
+
variants: deep(n.variants) as BundleNode['variants'],
|
|
68
|
+
values: n.values.map((v: BundleValue) => ({ ...v, value: deep(v.value) })),
|
|
69
|
+
...(n.access
|
|
70
|
+
? {
|
|
71
|
+
access: {
|
|
72
|
+
...n.access,
|
|
73
|
+
// A protected page naming a member names their stand-in; one naming
|
|
74
|
+
// someone who did not travel names nobody.
|
|
75
|
+
...(n.access.memberUserNames
|
|
76
|
+
? {
|
|
77
|
+
memberUserNames: n.access.memberUserNames.flatMap((name) => {
|
|
78
|
+
const standIn = usernames.get(name.toLowerCase())
|
|
79
|
+
return standIn ? [standIn] : []
|
|
80
|
+
}),
|
|
81
|
+
}
|
|
82
|
+
: {}),
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
: {}),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const member = (m: BundleMember, index: number): BundleMember => {
|
|
89
|
+
const n = index + 1
|
|
90
|
+
standIns.set(m.email.toLowerCase(), `member-${n}@${ANONYMISED_DOMAIN}`)
|
|
91
|
+
usernames.set(m.username.toLowerCase(), `member-${n}`)
|
|
92
|
+
return {
|
|
93
|
+
...m,
|
|
94
|
+
name: `Member ${n}`,
|
|
95
|
+
username: `member-${n}`,
|
|
96
|
+
email: `member-${n}@${ANONYMISED_DOMAIN}`,
|
|
97
|
+
passwordHash: null,
|
|
98
|
+
passwordConfig: null,
|
|
99
|
+
values: [],
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Members first, so an address that belongs to one maps to their stand-in
|
|
104
|
+
// wherever content mentions it.
|
|
105
|
+
const people = input.people
|
|
106
|
+
? {
|
|
107
|
+
memberGroups: input.people.memberGroups,
|
|
108
|
+
members: input.people.members.map(member),
|
|
109
|
+
userGroups: input.people.userGroups,
|
|
110
|
+
users: [],
|
|
111
|
+
}
|
|
112
|
+
: undefined
|
|
113
|
+
for (const user of input.people?.users ?? []) standInFor(user.email)
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
set: { ...input.set, nodes: input.set.nodes.map(node) },
|
|
117
|
+
people,
|
|
118
|
+
dictionary: input.dictionary === undefined ? undefined : scrub(input.dictionary),
|
|
119
|
+
emails: standIns.size,
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/write.ts
CHANGED
|
@@ -157,6 +157,7 @@ export function writeManifest(manifest: BundleManifest): string {
|
|
|
157
157
|
schemaVersion: manifest.provenance.schemaVersion,
|
|
158
158
|
schemaRevision: manifest.provenance.schemaRevision,
|
|
159
159
|
schemaHash: manifest.provenance.schemaHash,
|
|
160
|
+
...(manifest.provenance.cmsVersion ? { cmsVersion: manifest.provenance.cmsVersion } : {}),
|
|
160
161
|
},
|
|
161
162
|
selector: {
|
|
162
163
|
roots: manifest.selector.roots,
|
|
@@ -193,6 +194,11 @@ export function writeManifest(manifest: BundleManifest): string {
|
|
|
193
194
|
...(manifest.people ? { people: manifest.people } : {}),
|
|
194
195
|
...(manifest.seal ? { seal: manifest.seal } : {}),
|
|
195
196
|
...(manifest.content ? { content: manifest.content } : {}),
|
|
197
|
+
...(manifest.scope ? { scope: manifest.scope } : {}),
|
|
198
|
+
...(manifest.media ? { media: manifest.media } : {}),
|
|
199
|
+
...(manifest.requires && Object.keys(manifest.requires).length > 0
|
|
200
|
+
? { requires: Object.fromEntries(Object.entries(manifest.requires).sort()) }
|
|
201
|
+
: {}),
|
|
196
202
|
})
|
|
197
203
|
}
|
|
198
204
|
|
|
@@ -289,13 +295,56 @@ export function writeBundle(
|
|
|
289
295
|
sections: readonly BundleFile[] = [],
|
|
290
296
|
options: WriteOptions = {},
|
|
291
297
|
): BundleFile[] {
|
|
298
|
+
const sizes = new Map([...blobs].map(([key, bytes]) => [key, bytes.length]))
|
|
299
|
+
const planned = planBundle(set, sizes, sections, options)
|
|
300
|
+
const carried: BundleFile[] = planned.blobs.map((key) => {
|
|
301
|
+
const path = blobPath(key)
|
|
302
|
+
const bytes = blobs.get(key) as Uint8Array
|
|
303
|
+
return {
|
|
304
|
+
path,
|
|
305
|
+
bytes: planned.sealing && options.seal ? sealBytes(options.seal.key, path, bytes) : bytes,
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
const manifest = {
|
|
309
|
+
...planned.manifest,
|
|
310
|
+
integrity: bundleIntegrity([...planned.files, ...carried]),
|
|
311
|
+
}
|
|
312
|
+
return [{ path: MANIFEST_FILE, text: writeManifest(manifest) }, ...planned.files, ...carried]
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* A bundle whose media files the caller streams (story 104): the manifest and
|
|
317
|
+
* every other file, from each carried file's size alone. The caller writes
|
|
318
|
+
* `blobs` at `blobPath`, computes `bundleIntegrity` over those and `files` in
|
|
319
|
+
* path order, and writes the manifest with it. Open content only: sealing a
|
|
320
|
+
* file needs all of it at once.
|
|
321
|
+
*/
|
|
322
|
+
export function planStreamedBundle(
|
|
323
|
+
set: ContentSet,
|
|
324
|
+
blobSizes: ReadonlyMap<string, number>,
|
|
325
|
+
sections: readonly BundleFile[] = [],
|
|
326
|
+
options: Omit<WriteOptions, 'content'> & { content?: 'open' | 'omitted' } = {},
|
|
327
|
+
): { manifest: BundleManifest; files: BundleFile[]; blobs: string[] } {
|
|
328
|
+
const planned = planBundle(set, blobSizes, sections, {
|
|
329
|
+
...options,
|
|
330
|
+
content: options.content ?? 'open',
|
|
331
|
+
})
|
|
332
|
+
return { manifest: planned.manifest, files: planned.files, blobs: planned.blobs }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function planBundle(
|
|
336
|
+
set: ContentSet,
|
|
337
|
+
blobSizes: ReadonlyMap<string, number>,
|
|
338
|
+
sections: readonly BundleFile[],
|
|
339
|
+
options: WriteOptions,
|
|
340
|
+
): { manifest: BundleManifest; files: BundleFile[]; blobs: string[]; sealing: boolean } {
|
|
292
341
|
const seal = options.seal
|
|
293
342
|
const content = options.content ?? (seal ? 'sealed' : 'open')
|
|
294
343
|
if ((content === 'sealed' || options.people) && !seal)
|
|
295
344
|
throw new Error('Sealed content and people need a seal: give a passphrase.')
|
|
296
345
|
const sealing = content === 'sealed'
|
|
297
346
|
const source = content === 'omitted' ? set.nodes.map(withoutContent) : set.nodes
|
|
298
|
-
const
|
|
347
|
+
const carriedSizes: ReadonlyMap<string, number> = content === 'omitted' ? new Map() : blobSizes
|
|
299
348
|
|
|
300
349
|
const sorted = [...source].sort((a, b) => a.key.localeCompare(b.key))
|
|
301
350
|
const nodes: BundleFile[] = sorted.map((node) => ({
|
|
@@ -309,12 +358,6 @@ export function writeBundle(
|
|
|
309
358
|
return { path, bytes: sealBytes(seal.key, path, Buffer.from(json(sealedValues(node)))) }
|
|
310
359
|
})
|
|
311
360
|
: []
|
|
312
|
-
const carried: BundleFile[] = [...carriedBlobs]
|
|
313
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
314
|
-
.map(([key, bytes]) => {
|
|
315
|
-
const path = blobPath(key)
|
|
316
|
-
return { path, bytes: sealing && seal ? sealBytes(seal.key, path, bytes) : bytes }
|
|
317
|
-
})
|
|
318
361
|
|
|
319
362
|
// A file in no known section would travel without being declared, so it
|
|
320
363
|
// would not be hashed and a reader would never look for it. Refused here
|
|
@@ -345,27 +388,19 @@ export function writeBundle(
|
|
|
345
388
|
: CONTENT_ONLY_FORMAT_VERSION,
|
|
346
389
|
// Without values nothing names a media file, so none is expected either.
|
|
347
390
|
blobs: (content === 'omitted' ? [] : set.manifest.blobs).map((blob) => {
|
|
348
|
-
const
|
|
349
|
-
return
|
|
391
|
+
const size = carriedSizes.get(blob.key)
|
|
392
|
+
return size !== undefined ? { ...blob, included: true, size } : { ...blob, included: false }
|
|
350
393
|
}),
|
|
351
394
|
carries: extra.length > 0 ? carries : undefined,
|
|
352
395
|
people: people?.block,
|
|
353
396
|
seal: seal && (sealing || people) ? seal.params : undefined,
|
|
354
397
|
content: content === 'open' ? undefined : content,
|
|
355
|
-
integrity:
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
398
|
+
integrity: '',
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
manifest,
|
|
402
|
+
files: [...nodes, ...sealedFiles, ...extra, ...(people?.files ?? [])],
|
|
403
|
+
blobs: [...carriedSizes.keys()].sort((a, b) => a.localeCompare(b)),
|
|
404
|
+
sealing,
|
|
362
405
|
}
|
|
363
|
-
return [
|
|
364
|
-
{ path: MANIFEST_FILE, text: writeManifest(manifest) },
|
|
365
|
-
...nodes,
|
|
366
|
-
...sealedFiles,
|
|
367
|
-
...carried,
|
|
368
|
-
...extra,
|
|
369
|
-
...(people?.files ?? []),
|
|
370
|
-
]
|
|
371
406
|
}
|