@crombolo/import-umbraco 0.11.0 → 0.12.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 +5 -5
- package/src/content.ts +134 -2
- package/src/index.ts +76 -11
- package/src/inventory.ts +1 -35
- package/src/people.ts +275 -0
- package/src/site-files.ts +58 -0
- package/src/source.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/import-umbraco",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Imports an existing Umbraco site into crombolo: a compatibility report, then schema files, a content bundle and view stubs.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,10 +28,10 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
32
|
-
"@crombolo/data": "0.
|
|
33
|
-
"@crombolo/schema": "0.
|
|
34
|
-
"@crombolo/transfer": "0.
|
|
31
|
+
"@crombolo/core": "0.12.1",
|
|
32
|
+
"@crombolo/data": "0.12.1",
|
|
33
|
+
"@crombolo/schema": "0.12.1",
|
|
34
|
+
"@crombolo/transfer": "0.12.1",
|
|
35
35
|
"@kineco-au/bacpac-importer": "^0.1.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/content.ts
CHANGED
|
@@ -90,7 +90,7 @@ function blobKeyOf(value: unknown): string | undefined {
|
|
|
90
90
|
return undefined
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
type ValueRow = {
|
|
93
|
+
export type ValueRow = {
|
|
94
94
|
versionId: number
|
|
95
95
|
propertyTypeId: number
|
|
96
96
|
languageId: number | null
|
|
@@ -98,7 +98,7 @@ type ValueRow = {
|
|
|
98
98
|
} & Record<ValueColumn, unknown>
|
|
99
99
|
|
|
100
100
|
/** What the editor would be handed for a stored cell. */
|
|
101
|
-
function editorValue(editor: string, dbType: string, row: ValueRow): unknown {
|
|
101
|
+
export function editorValue(editor: string, dbType: string, row: ValueRow): unknown {
|
|
102
102
|
// The declared column first; an older database may still hold the value in
|
|
103
103
|
// another, as checkbox lists did before 17 moved them.
|
|
104
104
|
const declared = COLUMN_OF[dbType]
|
|
@@ -343,6 +343,36 @@ export function convertContent(
|
|
|
343
343
|
})
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
const rules = attachRules(source, schema, bundleNodes)
|
|
347
|
+
findings.some({
|
|
348
|
+
class: 'migrates',
|
|
349
|
+
code: 'public-access',
|
|
350
|
+
title: 'Protected pages',
|
|
351
|
+
detail:
|
|
352
|
+
'With the member groups and members allowed to see them, and their sign-in and no-access pages.',
|
|
353
|
+
count: rules.access,
|
|
354
|
+
})
|
|
355
|
+
findings.some({
|
|
356
|
+
class: 'migrates',
|
|
357
|
+
code: 'redirects',
|
|
358
|
+
title: 'Redirects',
|
|
359
|
+
detail: 'The addresses pages had before they moved, which keep sending visitors to them.',
|
|
360
|
+
count: rules.redirects,
|
|
361
|
+
})
|
|
362
|
+
findings.some({
|
|
363
|
+
class: 'migrates',
|
|
364
|
+
code: 'schedules',
|
|
365
|
+
title: 'Scheduled publishes',
|
|
366
|
+
detail: 'Pending publish and unpublish dates, which run here at the same times.',
|
|
367
|
+
count: rules.schedules,
|
|
368
|
+
})
|
|
369
|
+
findings.some({
|
|
370
|
+
class: 'dropped',
|
|
371
|
+
code: 'rules-for-missing-pages',
|
|
372
|
+
title: 'Protection, redirects or schedules for pages not imported',
|
|
373
|
+
count: rules.orphaned,
|
|
374
|
+
})
|
|
375
|
+
|
|
346
376
|
const bundleBlobs: BundleBlob[] = blobs.map((blob) => ({
|
|
347
377
|
key: blob.key,
|
|
348
378
|
node: blob.node,
|
|
@@ -433,3 +463,105 @@ export function convertContent(
|
|
|
433
463
|
},
|
|
434
464
|
}
|
|
435
465
|
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Umbraco's protected pages, scheduled publishes and recorded redirects, onto
|
|
469
|
+
* the nodes they belong to. Counts what was attached, and what named a page
|
|
470
|
+
* that is not being imported.
|
|
471
|
+
*/
|
|
472
|
+
function attachRules(
|
|
473
|
+
source: Source,
|
|
474
|
+
schema: ConvertedSchema,
|
|
475
|
+
nodes: BundleNode[],
|
|
476
|
+
): { access: number; schedules: number; redirects: number; orphaned: number } {
|
|
477
|
+
const counts = { access: 0, schedules: 0, redirects: 0, orphaned: 0 }
|
|
478
|
+
const byKey = new Map(nodes.map((node) => [node.key, node]))
|
|
479
|
+
const keyOf = new Map(
|
|
480
|
+
source
|
|
481
|
+
.all<{ id: number; key: string }>('SELECT id, uniqueId AS key FROM umbracoNode')
|
|
482
|
+
.map((row) => [Number(row.id), key(row.key)]),
|
|
483
|
+
)
|
|
484
|
+
const nodeFor = (id: unknown) => byKey.get(keyOf.get(Number(id)) ?? '')
|
|
485
|
+
|
|
486
|
+
if (source.has('umbracoAccess')) {
|
|
487
|
+
const rulesOf = new Map<string, Array<{ type: string; value: string }>>()
|
|
488
|
+
if (source.has('umbracoAccessRule'))
|
|
489
|
+
for (const row of source.all<{ accessId: unknown; ruleType: string; ruleValue: string }>(
|
|
490
|
+
'SELECT accessId, ruleType, ruleValue FROM umbracoAccessRule',
|
|
491
|
+
)) {
|
|
492
|
+
const id = String(row.accessId).toLowerCase()
|
|
493
|
+
rulesOf.set(id, [...(rulesOf.get(id) ?? []), { type: row.ruleType, value: row.ruleValue }])
|
|
494
|
+
}
|
|
495
|
+
for (const row of source.all<{
|
|
496
|
+
id: unknown
|
|
497
|
+
nodeId: number
|
|
498
|
+
loginNodeId: number
|
|
499
|
+
noAccessNodeId: number
|
|
500
|
+
}>('SELECT id, nodeId, loginNodeId, noAccessNodeId FROM umbracoAccess')) {
|
|
501
|
+
const node = nodeFor(row.nodeId)
|
|
502
|
+
const login = keyOf.get(Number(row.loginNodeId))
|
|
503
|
+
const error = keyOf.get(Number(row.noAccessNodeId))
|
|
504
|
+
if (!node || !login || !error || !byKey.has(login) || !byKey.has(error)) {
|
|
505
|
+
counts.orphaned++
|
|
506
|
+
continue
|
|
507
|
+
}
|
|
508
|
+
const rules = rulesOf.get(String(row.id).toLowerCase()) ?? []
|
|
509
|
+
node.access = {
|
|
510
|
+
loginNode: login,
|
|
511
|
+
errorNode: error,
|
|
512
|
+
memberGroups: rules.filter((r) => r.type === 'MemberRole').map((r) => r.value),
|
|
513
|
+
memberUserNames: rules.filter((r) => r.type === 'MemberUsername').map((r) => r.value),
|
|
514
|
+
}
|
|
515
|
+
counts.access++
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (source.has('umbracoContentSchedule'))
|
|
520
|
+
for (const row of source.all<{
|
|
521
|
+
nodeId: number
|
|
522
|
+
languageId: number | null
|
|
523
|
+
date: string
|
|
524
|
+
action: string
|
|
525
|
+
}>('SELECT nodeId, languageId, date, action FROM umbracoContentSchedule ORDER BY date')) {
|
|
526
|
+
const node = nodeFor(row.nodeId)
|
|
527
|
+
const when = wallClock(row.date)
|
|
528
|
+
if (node?.kind !== 'document' || !when) {
|
|
529
|
+
counts.orphaned++
|
|
530
|
+
continue
|
|
531
|
+
}
|
|
532
|
+
// Umbraco stores the time in UTC.
|
|
533
|
+
node.schedule = [
|
|
534
|
+
...(node.schedule ?? []),
|
|
535
|
+
{
|
|
536
|
+
culture:
|
|
537
|
+
row.languageId === null
|
|
538
|
+
? null
|
|
539
|
+
: (schema.languages.get(Number(row.languageId))?.iso ?? null),
|
|
540
|
+
action: row.action === 'Expire' ? 'unpublish' : 'publish',
|
|
541
|
+
date: new Date(`${when.replace(' ', 'T')}Z`).toISOString(),
|
|
542
|
+
},
|
|
543
|
+
]
|
|
544
|
+
counts.schedules++
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (source.has('umbracoRedirectUrl'))
|
|
548
|
+
for (const row of source.all<{ contentKey: string; url: string; culture: string | null }>(
|
|
549
|
+
'SELECT contentKey, url, culture FROM umbracoRedirectUrl ORDER BY createDateUtc',
|
|
550
|
+
)) {
|
|
551
|
+
const node = byKey.get(key(row.contentKey))
|
|
552
|
+
// `1234/about/` under a hostname root, or `/about/` without one.
|
|
553
|
+
const match = /^(\d+)?(\/.*)$/.exec(row.url)
|
|
554
|
+
if (!node || !match) {
|
|
555
|
+
counts.orphaned++
|
|
556
|
+
continue
|
|
557
|
+
}
|
|
558
|
+
const path = (match[2] as string).replace(/\/+$/, '').toLowerCase() || '/'
|
|
559
|
+
const root = match[1] ? (keyOf.get(Number(match[1])) ?? null) : null
|
|
560
|
+
const culture = row.culture ? row.culture : null
|
|
561
|
+
if (node.redirects?.some((r) => r.path === path && r.root === root && r.culture === culture))
|
|
562
|
+
continue
|
|
563
|
+
node.redirects = [...(node.redirects ?? []), { path, root, culture }]
|
|
564
|
+
counts.redirects++
|
|
565
|
+
}
|
|
566
|
+
return counts
|
|
567
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -18,10 +18,11 @@ import {
|
|
|
18
18
|
writeMemberType,
|
|
19
19
|
writeSchemaVersion,
|
|
20
20
|
} from '@crombolo/schema'
|
|
21
|
-
import { writeBundle, writeBundlePackage } from '@crombolo/transfer'
|
|
21
|
+
import { newSeal, writeBundle, writeBundlePackage } from '@crombolo/transfer'
|
|
22
22
|
import { convertContent } from './content.ts'
|
|
23
23
|
import { Findings } from './findings.ts'
|
|
24
24
|
import { type DictionaryItem, type DomainEntry, takeInventory } from './inventory.ts'
|
|
25
|
+
import { convertPeople } from './people.ts'
|
|
25
26
|
import { buildReport, type ImportReport, reportJson, reportMarkdown } from './report.ts'
|
|
26
27
|
import { convertSchema } from './schema.ts'
|
|
27
28
|
import { readSiteFiles, type SiteFiles } from './site-files.ts'
|
|
@@ -38,6 +39,7 @@ export {
|
|
|
38
39
|
reportSummary,
|
|
39
40
|
} from './report.ts'
|
|
40
41
|
export { SUPPORTED_EDITORS } from './schema.ts'
|
|
42
|
+
export { hideTopLevelFrom, parseAppSettings } from './site-files.ts'
|
|
41
43
|
export { openSource, type Source } from './source.ts'
|
|
42
44
|
export {
|
|
43
45
|
type DetectedVersion,
|
|
@@ -71,6 +73,15 @@ export interface ImportOptions {
|
|
|
71
73
|
* as its peer range. The importer does not know it itself: the CLI does.
|
|
72
74
|
*/
|
|
73
75
|
coreVersion: string
|
|
76
|
+
/**
|
|
77
|
+
* Property values and media files: left out (the default), sealed under the
|
|
78
|
+
* passphrase, or in the open. Names, the tree and the rules travel regardless.
|
|
79
|
+
*/
|
|
80
|
+
content?: 'omitted' | 'sealed' | 'open'
|
|
81
|
+
withMembers?: boolean
|
|
82
|
+
withUsers?: boolean
|
|
83
|
+
/** Seals the content and the people; needed for either. */
|
|
84
|
+
passphrase?: string
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
/** One file of the new site: literal text, or a file to copy. */
|
|
@@ -96,6 +107,10 @@ export interface ImportPlan {
|
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
export async function planImport(options: ImportOptions): Promise<ImportPlan> {
|
|
110
|
+
const mode = options.content ?? 'omitted'
|
|
111
|
+
const withPeople = Boolean(options.withMembers || options.withUsers)
|
|
112
|
+
if ((mode === 'sealed' || withPeople) && !options.passphrase)
|
|
113
|
+
throw new Error('Encrypted content and people need a passphrase.')
|
|
99
114
|
const source = await openSource(options.source, { staging: options.staging })
|
|
100
115
|
try {
|
|
101
116
|
const findings = new Findings()
|
|
@@ -175,11 +190,50 @@ export async function planImport(options: ImportOptions): Promise<ImportPlan> {
|
|
|
175
190
|
siteName,
|
|
176
191
|
})
|
|
177
192
|
const inventory = takeInventory(source, schema, files, findings)
|
|
193
|
+
const people = convertPeople(source, schema, findings, {
|
|
194
|
+
members: options.withMembers ?? false,
|
|
195
|
+
users: options.withUsers ?? false,
|
|
196
|
+
})
|
|
197
|
+
// What was left out by asking for less, and how to bring it.
|
|
198
|
+
if (!options.withMembers)
|
|
199
|
+
findings.some({
|
|
200
|
+
class: 'dropped',
|
|
201
|
+
code: 'members-left-out',
|
|
202
|
+
title: 'Members',
|
|
203
|
+
detail:
|
|
204
|
+
'Not asked for. Pass --with-members to bring them, with their groups and passwords, encrypted.',
|
|
205
|
+
count: source.count('cmsMember'),
|
|
206
|
+
})
|
|
207
|
+
if (!options.withUsers)
|
|
208
|
+
findings.some({
|
|
209
|
+
class: 'dropped',
|
|
210
|
+
code: 'users-left-out',
|
|
211
|
+
title: 'Backoffice users',
|
|
212
|
+
detail:
|
|
213
|
+
'Not asked for: `crombolo start` creates an administrator. Pass --with-users to bring them, with their groups and passwords, encrypted.',
|
|
214
|
+
count: source.count('umbracoUser', 'id > 0'),
|
|
215
|
+
})
|
|
216
|
+
if (mode === 'omitted')
|
|
217
|
+
findings.some({
|
|
218
|
+
class: 'dropped',
|
|
219
|
+
code: 'content-left-out',
|
|
220
|
+
title: 'Property values and media files',
|
|
221
|
+
detail:
|
|
222
|
+
'Not asked for: pages and media are created with their names, places and templates, and are empty. Pass --include-content to bring what they hold, encrypted.',
|
|
223
|
+
count: content.set.nodes.filter((node) => node.values.length > 0).length,
|
|
224
|
+
})
|
|
178
225
|
|
|
179
226
|
const out: SiteFile[] = []
|
|
180
227
|
|
|
181
228
|
// Schema
|
|
182
|
-
|
|
229
|
+
// The source's address setting travels, so its URLs stay what they were.
|
|
230
|
+
out.push({
|
|
231
|
+
path: 'schema/schema.toml',
|
|
232
|
+
text: writeSchemaVersion(
|
|
233
|
+
schema.set.version,
|
|
234
|
+
files?.hideTopLevelNodeFromPath === false ? { hideTopLevel: false } : undefined,
|
|
235
|
+
),
|
|
236
|
+
})
|
|
183
237
|
for (const type of schema.set.contentTypes)
|
|
184
238
|
out.push({
|
|
185
239
|
path: `schema/content-types/${fileNameFor(type.alias)}`,
|
|
@@ -262,7 +316,7 @@ export async function planImport(options: ImportOptions): Promise<ImportPlan> {
|
|
|
262
316
|
// the values name, so nothing has to hold a whole media library in memory.
|
|
263
317
|
const missing: string[] = []
|
|
264
318
|
const seen = new Set<string>()
|
|
265
|
-
for (const blob of content.blobs) {
|
|
319
|
+
for (const blob of mode === 'omitted' ? [] : content.blobs) {
|
|
266
320
|
if (seen.has(blob.key)) continue
|
|
267
321
|
seen.add(blob.key)
|
|
268
322
|
const file = files?.mediaDir ? join(files.mediaDir, blob.key) : undefined
|
|
@@ -301,14 +355,25 @@ export async function planImport(options: ImportOptions): Promise<ImportPlan> {
|
|
|
301
355
|
})
|
|
302
356
|
|
|
303
357
|
// The content, as the private bundle package `content import` reads.
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
358
|
+
const seal =
|
|
359
|
+
options.passphrase && (mode === 'sealed' || withPeople)
|
|
360
|
+
? newSeal(options.passphrase)
|
|
361
|
+
: undefined
|
|
362
|
+
const imported = writeBundlePackage(
|
|
363
|
+
writeBundle(content.set, undefined, [], {
|
|
364
|
+
content: mode,
|
|
365
|
+
seal,
|
|
366
|
+
people: withPeople ? people : undefined,
|
|
367
|
+
}),
|
|
368
|
+
{
|
|
369
|
+
name: 'umbraco-import',
|
|
370
|
+
version: '1.0.0',
|
|
371
|
+
id: 'umbraco-import',
|
|
372
|
+
label: `Content imported from ${siteName}`,
|
|
373
|
+
private: true,
|
|
374
|
+
peerRange: `^${options.coreVersion}`,
|
|
375
|
+
},
|
|
376
|
+
)
|
|
312
377
|
for (const file of imported)
|
|
313
378
|
out.push({ path: `${IMPORT_BUNDLE}/${file.path}`, text: file.text, bytes: file.bytes })
|
|
314
379
|
|
package/src/inventory.ts
CHANGED
|
@@ -277,40 +277,6 @@ export function takeInventory(
|
|
|
277
277
|
items: domains.map((d) => d.host || `(culture ${d.defaultCulture ?? 'default'})`),
|
|
278
278
|
})
|
|
279
279
|
|
|
280
|
-
// What a later version of the importer could carry, and this one does not.
|
|
281
|
-
const notYet = (code: string, title: string, count: number, detail: string) =>
|
|
282
|
-
findings.some({ class: 'not-yet', code, title, count, detail })
|
|
283
|
-
notYet(
|
|
284
|
-
'members',
|
|
285
|
-
'Members',
|
|
286
|
-
source.count('cmsMember'),
|
|
287
|
-
'Members, their groups and their passwords are not imported. Member types are.',
|
|
288
|
-
)
|
|
289
|
-
notYet(
|
|
290
|
-
'users',
|
|
291
|
-
'Backoffice users',
|
|
292
|
-
source.count('umbracoUser', 'id > 0'),
|
|
293
|
-
'Users and user groups are not imported. `crombolo start` creates an administrator.',
|
|
294
|
-
)
|
|
295
|
-
notYet(
|
|
296
|
-
'redirects',
|
|
297
|
-
'Redirects',
|
|
298
|
-
source.count('umbracoRedirectUrl'),
|
|
299
|
-
'Redirects Umbraco recorded when pages moved are not imported.',
|
|
300
|
-
)
|
|
301
|
-
notYet(
|
|
302
|
-
'public-access',
|
|
303
|
-
'Protected pages',
|
|
304
|
-
source.count('umbracoAccess'),
|
|
305
|
-
'Public access rules are not imported: these pages will be public until the rules are set again.',
|
|
306
|
-
)
|
|
307
|
-
notYet(
|
|
308
|
-
'schedules',
|
|
309
|
-
'Scheduled publishes',
|
|
310
|
-
source.count('umbracoContentSchedule'),
|
|
311
|
-
'Pending publish and unpublish dates are not imported.',
|
|
312
|
-
)
|
|
313
|
-
|
|
314
280
|
// What has no equivalent.
|
|
315
281
|
findings.some({
|
|
316
282
|
class: 'cannot-migrate',
|
|
@@ -402,7 +368,7 @@ export function takeInventory(
|
|
|
402
368
|
code: 'app-plugins',
|
|
403
369
|
title: 'Backoffice plugins in App_Plugins',
|
|
404
370
|
detail:
|
|
405
|
-
'A plugin that is client-side only may work
|
|
371
|
+
'A plugin that is client-side only may work once repackaged as a bundle in the new site’s bundles/; one with a server side will not.',
|
|
406
372
|
count: files.plugins.length,
|
|
407
373
|
items: files.plugins,
|
|
408
374
|
})
|
package/src/people.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Umbraco's members, member groups, users and user groups → a bundle's people
|
|
3
|
+
* (story 071), each password hash kept with its algorithm marker so it still
|
|
4
|
+
* verifies here and is re-hashed on the next sign-in.
|
|
5
|
+
*/
|
|
6
|
+
import { ObjectTypes } from '@crombolo/core'
|
|
7
|
+
import type {
|
|
8
|
+
BundleMember,
|
|
9
|
+
BundlePeople,
|
|
10
|
+
BundleUser,
|
|
11
|
+
BundleUserGroup,
|
|
12
|
+
BundleValue,
|
|
13
|
+
} from '@crombolo/transfer'
|
|
14
|
+
import { editorValue, type ValueRow } from './content.ts'
|
|
15
|
+
import type { Findings } from './findings.ts'
|
|
16
|
+
import type { ConvertedSchema } from './schema.ts'
|
|
17
|
+
import { key, type Source } from './source.ts'
|
|
18
|
+
|
|
19
|
+
export interface PeopleOptions {
|
|
20
|
+
members: boolean
|
|
21
|
+
users: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const truthy = (value: unknown): boolean => value === 1 || value === true || value === '1'
|
|
25
|
+
const text = (value: unknown): string | null =>
|
|
26
|
+
value === null || value === undefined || value === '' ? null : String(value)
|
|
27
|
+
|
|
28
|
+
export function convertPeople(
|
|
29
|
+
source: Source,
|
|
30
|
+
schema: ConvertedSchema,
|
|
31
|
+
findings: Findings,
|
|
32
|
+
options: PeopleOptions,
|
|
33
|
+
): BundlePeople {
|
|
34
|
+
const people: BundlePeople = { memberGroups: [], members: [], userGroups: [], users: [] }
|
|
35
|
+
const nodeKeys = new Map(
|
|
36
|
+
source
|
|
37
|
+
.all<{ id: number; key: string }>('SELECT id, uniqueId AS key FROM umbracoNode')
|
|
38
|
+
.map((row) => [Number(row.id), key(row.key)]),
|
|
39
|
+
)
|
|
40
|
+
if (options.members) convertMembers(source, schema, nodeKeys, people)
|
|
41
|
+
if (options.users) convertUsers(source, nodeKeys, people)
|
|
42
|
+
|
|
43
|
+
findings.some({
|
|
44
|
+
class: 'migrates',
|
|
45
|
+
code: 'members',
|
|
46
|
+
title: 'Members',
|
|
47
|
+
detail:
|
|
48
|
+
'With their groups and their passwords, which keep working and are re-hashed the first time each member signs in. They travel encrypted.',
|
|
49
|
+
count: people.members.length,
|
|
50
|
+
})
|
|
51
|
+
findings.some({
|
|
52
|
+
class: 'migrates',
|
|
53
|
+
code: 'users',
|
|
54
|
+
title: 'Backoffice users',
|
|
55
|
+
detail:
|
|
56
|
+
'With their groups and their passwords, which keep working and are re-hashed the first time each user signs in. They travel encrypted.',
|
|
57
|
+
count: people.users.length,
|
|
58
|
+
})
|
|
59
|
+
return people
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function convertMembers(
|
|
63
|
+
source: Source,
|
|
64
|
+
schema: ConvertedSchema,
|
|
65
|
+
nodeKeys: ReadonlyMap<number, string>,
|
|
66
|
+
people: BundlePeople,
|
|
67
|
+
): void {
|
|
68
|
+
people.memberGroups = source
|
|
69
|
+
.all<{ key: string; name: string }>(
|
|
70
|
+
`SELECT uniqueId AS key, "text" AS name FROM umbracoNode
|
|
71
|
+
WHERE nodeObjectType = ? COLLATE NOCASE AND trashed = 0 ORDER BY "text"`,
|
|
72
|
+
ObjectTypes.MemberGroup,
|
|
73
|
+
)
|
|
74
|
+
.map((row) => ({ key: key(row.key), name: row.name }))
|
|
75
|
+
if (!source.has('cmsMember')) return
|
|
76
|
+
|
|
77
|
+
const groupsOf = new Map<number, string[]>()
|
|
78
|
+
if (source.has('cmsMember2MemberGroup'))
|
|
79
|
+
for (const row of source.all<{ member: number; group: number }>(
|
|
80
|
+
'SELECT Member AS member, MemberGroup AS "group" FROM cmsMember2MemberGroup',
|
|
81
|
+
)) {
|
|
82
|
+
const group = nodeKeys.get(Number(row.group))
|
|
83
|
+
if (!group) continue
|
|
84
|
+
const list = groupsOf.get(Number(row.member)) ?? []
|
|
85
|
+
list.push(group)
|
|
86
|
+
groupsOf.set(Number(row.member), list)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const rows = source.all<{
|
|
90
|
+
id: number
|
|
91
|
+
name: string | null
|
|
92
|
+
contentTypeId: number
|
|
93
|
+
versionId: number | null
|
|
94
|
+
email: string
|
|
95
|
+
login: string
|
|
96
|
+
password: string | null
|
|
97
|
+
passwordConfig: string | null
|
|
98
|
+
isApproved: unknown
|
|
99
|
+
isLockedOut: unknown
|
|
100
|
+
}>(
|
|
101
|
+
`SELECT n.id AS id, n."text" AS name, c.contentTypeId AS contentTypeId, cv.id AS versionId,
|
|
102
|
+
m.Email AS email, m.LoginName AS login, m.Password AS password,
|
|
103
|
+
m.passwordConfig AS passwordConfig, m.isApproved AS isApproved, m.isLockedOut AS isLockedOut
|
|
104
|
+
FROM cmsMember m
|
|
105
|
+
JOIN umbracoNode n ON n.id = m.nodeId
|
|
106
|
+
JOIN umbracoContent c ON c.nodeId = n.id
|
|
107
|
+
LEFT JOIN umbracoContentVersion cv ON cv.nodeId = n.id AND cv."current" = 1
|
|
108
|
+
ORDER BY n.id`,
|
|
109
|
+
)
|
|
110
|
+
const versions = new Set(
|
|
111
|
+
rows.flatMap((row) => (row.versionId === null ? [] : [Number(row.versionId)])),
|
|
112
|
+
)
|
|
113
|
+
const valuesByVersion = new Map<number, ValueRow[]>()
|
|
114
|
+
for (const row of source.all<ValueRow>(
|
|
115
|
+
`SELECT versionId, propertyTypeId, languageId, segment, intValue, decimalValue, dateValue,
|
|
116
|
+
varcharValue, textValue FROM umbracoPropertyData`,
|
|
117
|
+
)) {
|
|
118
|
+
const version = Number(row.versionId)
|
|
119
|
+
if (!versions.has(version)) continue
|
|
120
|
+
const list = valuesByVersion.get(version) ?? []
|
|
121
|
+
list.push(row)
|
|
122
|
+
valuesByVersion.set(version, list)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const row of rows) {
|
|
126
|
+
const type = schema.contentTypes.get(Number(row.contentTypeId))
|
|
127
|
+
const memberKey = nodeKeys.get(Number(row.id))
|
|
128
|
+
if (!type || !memberKey) continue
|
|
129
|
+
const values: BundleValue[] = []
|
|
130
|
+
for (const cell of row.versionId === null
|
|
131
|
+
? []
|
|
132
|
+
: (valuesByVersion.get(Number(row.versionId)) ?? [])) {
|
|
133
|
+
const property = schema.propertyTypes.get(Number(cell.propertyTypeId))
|
|
134
|
+
if (!property) continue
|
|
135
|
+
const value = editorValue(property.dataType.editor, property.dataType.dbType, cell)
|
|
136
|
+
if (value === null || value === undefined) continue
|
|
137
|
+
values.push({
|
|
138
|
+
property: property.alias,
|
|
139
|
+
culture: null,
|
|
140
|
+
segment: cell.segment ?? null,
|
|
141
|
+
editor: property.dataType.editor,
|
|
142
|
+
value,
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
const member: BundleMember = {
|
|
146
|
+
key: memberKey,
|
|
147
|
+
name: row.name ?? row.login,
|
|
148
|
+
memberTypeAlias: type.alias,
|
|
149
|
+
email: row.email,
|
|
150
|
+
username: row.login,
|
|
151
|
+
isApproved: truthy(row.isApproved),
|
|
152
|
+
isLockedOut: truthy(row.isLockedOut),
|
|
153
|
+
passwordHash: text(row.password),
|
|
154
|
+
passwordConfig: text(row.passwordConfig),
|
|
155
|
+
groupKeys: groupsOf.get(Number(row.id)) ?? [],
|
|
156
|
+
values,
|
|
157
|
+
}
|
|
158
|
+
people.members.push(member)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function convertUsers(
|
|
163
|
+
source: Source,
|
|
164
|
+
nodeKeys: ReadonlyMap<number, string>,
|
|
165
|
+
people: BundlePeople,
|
|
166
|
+
): void {
|
|
167
|
+
if (!source.has('umbracoUserGroup') || !source.has('umbracoUser')) return
|
|
168
|
+
const groupRows = source.all<{
|
|
169
|
+
id: number
|
|
170
|
+
key: string | null
|
|
171
|
+
alias: string
|
|
172
|
+
name: string
|
|
173
|
+
icon: string | null
|
|
174
|
+
allLanguages: unknown
|
|
175
|
+
startContentId: number | null
|
|
176
|
+
startMediaId: number | null
|
|
177
|
+
}>(
|
|
178
|
+
`SELECT id, "key" AS key, userGroupAlias AS alias, userGroupName AS name, icon,
|
|
179
|
+
hasAccessToAllLanguages AS allLanguages, startContentId, startMediaId
|
|
180
|
+
FROM umbracoUserGroup ORDER BY id`,
|
|
181
|
+
)
|
|
182
|
+
const appsOf = new Map<number, string[]>()
|
|
183
|
+
if (source.has('umbracoUserGroup2App'))
|
|
184
|
+
for (const row of source.all<{ group: number; app: string }>(
|
|
185
|
+
'SELECT userGroupId AS "group", app FROM umbracoUserGroup2App ORDER BY app',
|
|
186
|
+
))
|
|
187
|
+
appsOf.set(Number(row.group), [...(appsOf.get(Number(row.group)) ?? []), row.app])
|
|
188
|
+
const permissionsOf = new Map<string, string[]>()
|
|
189
|
+
if (source.has('umbracoUserGroup2Permission'))
|
|
190
|
+
for (const row of source.all<{ group: string; permission: string }>(
|
|
191
|
+
'SELECT userGroupKey AS "group", permission FROM umbracoUserGroup2Permission ORDER BY permission',
|
|
192
|
+
))
|
|
193
|
+
permissionsOf.set(key(row.group), [
|
|
194
|
+
...(permissionsOf.get(key(row.group)) ?? []),
|
|
195
|
+
row.permission,
|
|
196
|
+
])
|
|
197
|
+
const languagesOf = new Map<number, string[]>()
|
|
198
|
+
if (source.has('umbracoUserGroup2Language') && source.has('umbracoLanguage'))
|
|
199
|
+
for (const row of source.all<{ group: number; iso: string }>(
|
|
200
|
+
`SELECT g.userGroupId AS "group", l.languageISOCode AS iso
|
|
201
|
+
FROM umbracoUserGroup2Language g JOIN umbracoLanguage l ON l.id = g.languageId`,
|
|
202
|
+
))
|
|
203
|
+
languagesOf.set(Number(row.group), [...(languagesOf.get(Number(row.group)) ?? []), row.iso])
|
|
204
|
+
|
|
205
|
+
const aliasOf = new Map<number, string>()
|
|
206
|
+
for (const row of groupRows) {
|
|
207
|
+
// Before Umbraco 14 a group had no key: one derived from its alias is stable across runs.
|
|
208
|
+
const groupKey = row.key ? key(row.key) : derivedKey(`user-group:${row.alias}`)
|
|
209
|
+
aliasOf.set(Number(row.id), row.alias)
|
|
210
|
+
const group: BundleUserGroup = {
|
|
211
|
+
key: groupKey,
|
|
212
|
+
alias: row.alias,
|
|
213
|
+
name: row.name,
|
|
214
|
+
icon: row.icon,
|
|
215
|
+
sections: appsOf.get(Number(row.id)) ?? [],
|
|
216
|
+
permissions: permissionsOf.get(groupKey) ?? [],
|
|
217
|
+
hasAccessToAllLanguages: truthy(row.allLanguages),
|
|
218
|
+
languages: languagesOf.get(Number(row.id)) ?? [],
|
|
219
|
+
documentStartNodeKey:
|
|
220
|
+
row.startContentId === null ? null : (nodeKeys.get(Number(row.startContentId)) ?? null),
|
|
221
|
+
mediaStartNodeKey:
|
|
222
|
+
row.startMediaId === null ? null : (nodeKeys.get(Number(row.startMediaId)) ?? null),
|
|
223
|
+
}
|
|
224
|
+
people.userGroups.push(group)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const groupsOf = new Map<number, string[]>()
|
|
228
|
+
if (source.has('umbracoUser2UserGroup'))
|
|
229
|
+
for (const row of source.all<{ user: number; group: number }>(
|
|
230
|
+
'SELECT userId AS "user", userGroupId AS "group" FROM umbracoUser2UserGroup',
|
|
231
|
+
)) {
|
|
232
|
+
const alias = aliasOf.get(Number(row.group))
|
|
233
|
+
if (alias) groupsOf.set(Number(row.user), [...(groupsOf.get(Number(row.user)) ?? []), alias])
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const hasKind = source.hasColumn('umbracoUser', 'kind')
|
|
237
|
+
for (const row of source.all<{
|
|
238
|
+
id: number
|
|
239
|
+
key: string | null
|
|
240
|
+
name: string
|
|
241
|
+
login: string
|
|
242
|
+
email: string
|
|
243
|
+
language: string | null
|
|
244
|
+
disabled: unknown
|
|
245
|
+
password: string | null
|
|
246
|
+
passwordConfig: string | null
|
|
247
|
+
kind: number | null
|
|
248
|
+
}>(
|
|
249
|
+
`SELECT id, "key" AS key, userName AS name, userLogin AS login, userEmail AS email,
|
|
250
|
+
userLanguage AS language, userDisabled AS disabled, userPassword AS password,
|
|
251
|
+
passwordConfig, ${hasKind ? 'kind' : 'NULL AS kind'}
|
|
252
|
+
FROM umbracoUser ORDER BY id`,
|
|
253
|
+
)) {
|
|
254
|
+
// API users sign in with client credentials, which do not travel.
|
|
255
|
+
if (Number(row.kind ?? 0) !== 0) continue
|
|
256
|
+
const user: BundleUser = {
|
|
257
|
+
key: row.key ? key(row.key) : derivedKey(`user:${row.login}`),
|
|
258
|
+
name: row.name,
|
|
259
|
+
userName: row.login,
|
|
260
|
+
email: row.email,
|
|
261
|
+
languageIsoCode: row.language,
|
|
262
|
+
disabled: truthy(row.disabled),
|
|
263
|
+
passwordHash: text(row.password),
|
|
264
|
+
passwordConfig: text(row.passwordConfig),
|
|
265
|
+
groupAliases: groupsOf.get(Number(row.id)) ?? [],
|
|
266
|
+
}
|
|
267
|
+
people.users.push(user)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A uuid-shaped key from a name, the same every run. Pure. */
|
|
272
|
+
function derivedKey(name: string): string {
|
|
273
|
+
const hex = new Bun.CryptoHasher('sha256').update(name).digest('hex')
|
|
274
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`
|
|
275
|
+
}
|
package/src/site-files.ts
CHANGED
|
@@ -20,6 +20,51 @@ export interface SiteFiles {
|
|
|
20
20
|
csharp: number
|
|
21
21
|
/** Stylesheets and scripts under the web root, relative to it. */
|
|
22
22
|
assets: Array<{ path: string; file: string }>
|
|
23
|
+
/** `Umbraco:CMS:Global:HideTopLevelNodeFromPath`, when the settings name it. */
|
|
24
|
+
hideTopLevelNodeFromPath?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** JSON as .NET reads appsettings: comments and trailing commas allowed. Pure. */
|
|
28
|
+
export function parseAppSettings(text: string): unknown {
|
|
29
|
+
let out = ''
|
|
30
|
+
let inString = false
|
|
31
|
+
for (let i = 0; i < text.length; i++) {
|
|
32
|
+
const c = text[i] as string
|
|
33
|
+
if (inString) {
|
|
34
|
+
out += c
|
|
35
|
+
if (c === '\\') out += text[++i] ?? ''
|
|
36
|
+
else if (c === '"') inString = false
|
|
37
|
+
} else if (c === '"') {
|
|
38
|
+
inString = true
|
|
39
|
+
out += c
|
|
40
|
+
} else if (c === '/' && text[i + 1] === '/') {
|
|
41
|
+
while (i < text.length && text[i] !== '\n') i++
|
|
42
|
+
} else if (c === '/' && text[i + 1] === '*') {
|
|
43
|
+
i = text.indexOf('*/', i + 2)
|
|
44
|
+
if (i < 0) break
|
|
45
|
+
i++
|
|
46
|
+
} else out += c
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'))
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The setting from parsed appsettings, matching keys as .NET does, ignoring case. Pure. */
|
|
56
|
+
export function hideTopLevelFrom(settings: unknown): boolean | undefined {
|
|
57
|
+
let at: unknown = settings
|
|
58
|
+
for (const name of ['umbraco', 'cms', 'global', 'hidetoplevelnodefrompath']) {
|
|
59
|
+
if (!at || typeof at !== 'object') return undefined
|
|
60
|
+
const entry = Object.entries(at as Record<string, unknown>).find(
|
|
61
|
+
([key]) => key.toLowerCase() === name,
|
|
62
|
+
)
|
|
63
|
+
at = entry?.[1]
|
|
64
|
+
}
|
|
65
|
+
if (typeof at === 'boolean') return at
|
|
66
|
+
if (typeof at === 'string' && /^(true|false)$/i.test(at)) return at.toLowerCase() === 'true'
|
|
67
|
+
return undefined
|
|
23
68
|
}
|
|
24
69
|
|
|
25
70
|
const SKIP = new Set(['bin', 'obj', 'node_modules', '.git', '.vs', 'umbraco'])
|
|
@@ -114,5 +159,18 @@ export function readSiteFiles(root: string, mediaOverride?: string): SiteFiles {
|
|
|
114
159
|
plugins: plugins.sort(),
|
|
115
160
|
csharp,
|
|
116
161
|
assets: assets.sort((a, b) => a.path.localeCompare(b.path)),
|
|
162
|
+
...urlSettingsIn(webRoot),
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The production settings override the base ones, as they do when the site runs. */
|
|
167
|
+
function urlSettingsIn(webRoot: string): Pick<SiteFiles, 'hideTopLevelNodeFromPath'> {
|
|
168
|
+
let found: boolean | undefined
|
|
169
|
+
for (const name of ['appsettings.json', 'appsettings.Production.json']) {
|
|
170
|
+
const file = join(webRoot, name)
|
|
171
|
+
if (!existsSync(file)) continue
|
|
172
|
+
const value = hideTopLevelFrom(parseAppSettings(readFileSync(file, 'utf8')))
|
|
173
|
+
if (value !== undefined) found = value
|
|
117
174
|
}
|
|
175
|
+
return found === undefined ? {} : { hideTopLevelNodeFromPath: found }
|
|
118
176
|
}
|
package/src/source.ts
CHANGED
|
@@ -41,6 +41,10 @@ const WANTED_TABLES = [
|
|
|
41
41
|
'umbracoAccessRule',
|
|
42
42
|
'umbracoUser',
|
|
43
43
|
'umbracoUserGroup',
|
|
44
|
+
'umbracoUser2UserGroup',
|
|
45
|
+
'umbracoUserGroup2App',
|
|
46
|
+
'umbracoUserGroup2Permission',
|
|
47
|
+
'umbracoUserGroup2Language',
|
|
44
48
|
'umbracoExternalLogin',
|
|
45
49
|
'umbracoTwoFactorLogin',
|
|
46
50
|
'umbracoWebhook',
|