@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/import-umbraco",
3
- "version": "0.11.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.11.0",
32
- "@crombolo/data": "0.11.0",
33
- "@crombolo/schema": "0.11.0",
34
- "@crombolo/transfer": "0.11.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
- out.push({ path: 'schema/schema.toml', text: writeSchemaVersion(schema.set.version) })
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 imported = writeBundlePackage(writeBundle(content.set), {
305
- name: 'umbraco-import',
306
- version: '1.0.0',
307
- id: 'umbraco-import',
308
- label: `Content imported from ${siteName}`,
309
- private: true,
310
- peerRange: `^${options.coreVersion}`,
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 if copied into the new site’s App_Plugins; one with a server side will not.',
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',