@crombolo/server 0.13.2 → 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 +12 -12
- package/src/adapters/bundles.ts +20 -139
- package/src/adapters/members.ts +22 -10
- package/src/adapters/schema-proposals.ts +10 -2
- package/src/adapters/users.ts +23 -1
- package/src/backoffice-external.ts +99 -0
- package/src/bundle-install.ts +261 -77
- package/src/bundle-sections.ts +160 -0
- package/src/bundles.ts +4 -0
- package/src/config.ts +15 -1
- package/src/delivery-members.ts +11 -2
- package/src/external-sign-in.ts +270 -0
- package/src/identity-provider-overrides.ts +283 -0
- package/src/identity-providers.ts +185 -0
- package/src/index.ts +8 -0
- package/src/media-store-azure.ts +6 -0
- package/src/media-store-s3.ts +1 -0
- package/src/media-store.ts +21 -0
- package/src/member-auth.ts +232 -15
- package/src/ports.ts +55 -1
- package/src/schema-store.ts +16 -0
- package/src/schema.ts +5 -7
- package/src/server.ts +112 -7
- package/src/site-file.ts +258 -0
- package/src/site-replace.ts +188 -0
- package/src/site-search.ts +51 -0
- package/src/site-sections.ts +234 -0
- package/src/tar.ts +155 -0
- package/src/two-factor.ts +138 -0
package/src/ports.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
ServerPort,
|
|
13
13
|
TemporaryFilePort,
|
|
14
14
|
UserItemPort,
|
|
15
|
+
UserTwoFactorPort,
|
|
15
16
|
} from '@crombolo/api-management'
|
|
16
17
|
import { AuthStore, expireCookie, serializeCookie } from '@crombolo/auth'
|
|
17
18
|
import {
|
|
@@ -84,6 +85,7 @@ import { createOEmbedService } from './oembed.ts'
|
|
|
84
85
|
import { createRedirectTracker } from './redirects.ts'
|
|
85
86
|
import { componentAliasesIn, type SchemaBoot } from './schema.ts'
|
|
86
87
|
import { publishSchema } from './schema-store.ts'
|
|
88
|
+
import { TwoFactorService, USER_TWO_FACTOR_PROVIDER } from './two-factor.ts'
|
|
87
89
|
import { createWebhookService, type WebhookService } from './webhooks.ts'
|
|
88
90
|
|
|
89
91
|
/** Re-exported for tests; the canonical list lives in @crombolo/core. */
|
|
@@ -492,7 +494,22 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
492
494
|
await store.endAllSessionsForUser(userId)
|
|
493
495
|
},
|
|
494
496
|
})
|
|
495
|
-
|
|
497
|
+
// Story 032: the authenticator provider, offered only where secrets can be sealed.
|
|
498
|
+
const twoFactor = new TwoFactorService(options.db, {
|
|
499
|
+
secretKey: options.config.secretKey,
|
|
500
|
+
issuer: options.config.siteName,
|
|
501
|
+
})
|
|
502
|
+
const configuration = userPorts.users.configuration
|
|
503
|
+
const currentConfiguration = userPorts.users.currentConfiguration
|
|
504
|
+
deps.userManagement = {
|
|
505
|
+
...userPorts.users,
|
|
506
|
+
configuration: () => ({ ...configuration(), allowTwoFactor: twoFactor.available }),
|
|
507
|
+
currentConfiguration: () => ({
|
|
508
|
+
...currentConfiguration(),
|
|
509
|
+
allowTwoFactor: twoFactor.available,
|
|
510
|
+
}),
|
|
511
|
+
twoFactor: userTwoFactorPort(twoFactor),
|
|
512
|
+
}
|
|
496
513
|
deps.userGroups = userPorts.groups
|
|
497
514
|
deps.userData = userPorts.userData
|
|
498
515
|
deps.media = createMediaPort(options.db, mediaFiles, {
|
|
@@ -621,3 +638,40 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
621
638
|
}
|
|
622
639
|
return deps
|
|
623
640
|
}
|
|
641
|
+
|
|
642
|
+
/** The user operations' view of two-factor sign-in: the one authenticator provider. */
|
|
643
|
+
function userTwoFactorPort(service: TwoFactorService): UserTwoFactorPort {
|
|
644
|
+
const provider = USER_TWO_FACTOR_PROVIDER
|
|
645
|
+
return {
|
|
646
|
+
providerName: provider,
|
|
647
|
+
async list(userKey) {
|
|
648
|
+
return [
|
|
649
|
+
{
|
|
650
|
+
providerName: provider,
|
|
651
|
+
isEnabledOnUser: (await service.providers(userKey)).includes(provider),
|
|
652
|
+
},
|
|
653
|
+
]
|
|
654
|
+
},
|
|
655
|
+
setup(account) {
|
|
656
|
+
const setup = service.setup(account)
|
|
657
|
+
return setup.ok
|
|
658
|
+
? {
|
|
659
|
+
ok: true,
|
|
660
|
+
secret: setup.value.secret,
|
|
661
|
+
qrCodeSetupImageUrl: setup.value.qrCodeSetupImageUrl,
|
|
662
|
+
}
|
|
663
|
+
: { ok: false, message: setup.message }
|
|
664
|
+
},
|
|
665
|
+
async enable(userKey, code, secret) {
|
|
666
|
+
const enabled = await service.enable(userKey, provider, secret, code)
|
|
667
|
+
return enabled.ok
|
|
668
|
+
? { ok: true, recoveryCodes: enabled.value.recoveryCodes }
|
|
669
|
+
: { ok: false, message: enabled.message }
|
|
670
|
+
},
|
|
671
|
+
async disable(userKey, code) {
|
|
672
|
+
const disabled = await service.disable(userKey, provider, code)
|
|
673
|
+
return disabled.ok ? { ok: true } : { ok: false, message: disabled.message }
|
|
674
|
+
},
|
|
675
|
+
reset: (userKey) => service.reset(userKey, provider),
|
|
676
|
+
}
|
|
677
|
+
}
|
package/src/schema-store.ts
CHANGED
|
@@ -111,6 +111,22 @@ export async function materialiseSchema(store: SchemaStore, dir: string): Promis
|
|
|
111
111
|
return keys
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Points a config at its store's schema: materialises the store into
|
|
116
|
+
* `schemaCacheDir` and makes that the schema directory. A no-op without a store;
|
|
117
|
+
* answers how many files were copied.
|
|
118
|
+
*/
|
|
119
|
+
export async function useSchemaStore(config: {
|
|
120
|
+
schemaStore: SchemaStore | undefined
|
|
121
|
+
schemaCacheDir: string
|
|
122
|
+
schemaDir: string
|
|
123
|
+
}): Promise<number | undefined> {
|
|
124
|
+
if (!config.schemaStore) return undefined
|
|
125
|
+
const copied = await materialiseSchema(config.schemaStore, config.schemaCacheDir)
|
|
126
|
+
config.schemaDir = config.schemaCacheDir
|
|
127
|
+
return copied.length
|
|
128
|
+
}
|
|
129
|
+
|
|
114
130
|
export interface PublishReport {
|
|
115
131
|
written: string[]
|
|
116
132
|
removed: string[]
|
package/src/schema.ts
CHANGED
|
@@ -43,7 +43,7 @@ import { listComponents } from './components.ts'
|
|
|
43
43
|
import { type CromboloConfig, VERSION } from './config.ts'
|
|
44
44
|
import { formTypesFor } from './form-types.ts'
|
|
45
45
|
import { logger } from './logging.ts'
|
|
46
|
-
import {
|
|
46
|
+
import { useSchemaStore } from './schema-store.ts'
|
|
47
47
|
|
|
48
48
|
export interface SchemaBoot {
|
|
49
49
|
/** The state this node runs at: the files' version, the deploy's revision. */
|
|
@@ -397,13 +397,11 @@ export async function importSchema(
|
|
|
397
397
|
db: Db,
|
|
398
398
|
config: CromboloConfig,
|
|
399
399
|
boot: { nodeId: string; nodeState: NodeSchemaState },
|
|
400
|
-
options: { dryRun?: boolean } = {},
|
|
400
|
+
options: { dryRun?: boolean; materialise?: boolean } = {},
|
|
401
401
|
): Promise<SchemaImportResult> {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
config.schemaDir = config.schemaCacheDir
|
|
406
|
-
}
|
|
402
|
+
// A caller that has just written into the directory says not to, or the store's
|
|
403
|
+
// older copy would be laid over its write.
|
|
404
|
+
const materialised = options.materialise === false ? undefined : await useSchemaStore(config)
|
|
407
405
|
if (!existsSync(config.schemaDir)) {
|
|
408
406
|
return { materialised, classification: 'none', findings: [], report: undefined }
|
|
409
407
|
}
|
package/src/server.ts
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
hasAccessToSensitiveData,
|
|
47
47
|
localReturnUrl,
|
|
48
48
|
MANAGEMENT_API_PREFIX,
|
|
49
|
+
ObjectTypes,
|
|
49
50
|
PREVIEW_HUB_PATH,
|
|
50
51
|
SERVER_EVENT_HUB_PATH,
|
|
51
52
|
} from '@crombolo/core'
|
|
@@ -53,6 +54,7 @@ import {
|
|
|
53
54
|
appendCacheInstruction,
|
|
54
55
|
createTablesCapability,
|
|
55
56
|
type Db,
|
|
57
|
+
ensureKeyValue,
|
|
56
58
|
FormEntryRepository,
|
|
57
59
|
FormWorkflowRepository,
|
|
58
60
|
PublicAccessRepository,
|
|
@@ -60,7 +62,9 @@ import {
|
|
|
60
62
|
readLedger,
|
|
61
63
|
readReport,
|
|
62
64
|
readUrlOptions,
|
|
65
|
+
SiteSearch,
|
|
63
66
|
TransferRunRepository,
|
|
67
|
+
UserGroupRepository,
|
|
64
68
|
type WritesPaused,
|
|
65
69
|
} from '@crombolo/data'
|
|
66
70
|
import {
|
|
@@ -69,6 +73,7 @@ import {
|
|
|
69
73
|
FORM_SCRIPT,
|
|
70
74
|
FORM_SCRIPT_PATH,
|
|
71
75
|
FORM_SUBMIT_PATH,
|
|
76
|
+
MEMBER_EXTERNAL_SIGN_IN_PATH,
|
|
72
77
|
PublishedCache,
|
|
73
78
|
Renderer,
|
|
74
79
|
type SnapshotStatus,
|
|
@@ -141,6 +146,12 @@ import {
|
|
|
141
146
|
|
|
142
147
|
export { PREVIEW_COOKIE }
|
|
143
148
|
|
|
149
|
+
import { BACKOFFICE_EXTERNAL_PATH, backofficeAudience } from './backoffice-external.ts'
|
|
150
|
+
import { createExternalSignIn } from './external-sign-in.ts'
|
|
151
|
+
import {
|
|
152
|
+
createBundleIdentityProviders,
|
|
153
|
+
IdentityProviderOverrides,
|
|
154
|
+
} from './identity-provider-overrides.ts'
|
|
144
155
|
import { syncConfiguredRedirects } from './redirects.ts'
|
|
145
156
|
import {
|
|
146
157
|
bootSchema,
|
|
@@ -149,7 +160,7 @@ import {
|
|
|
149
160
|
type SchemaBoot,
|
|
150
161
|
watchSchema,
|
|
151
162
|
} from './schema.ts'
|
|
152
|
-
import {
|
|
163
|
+
import { publishSchema, type SchemaStore, useSchemaStore } from './schema-store.ts'
|
|
153
164
|
import { createSeoHost, originOf } from './seo.ts'
|
|
154
165
|
import {
|
|
155
166
|
combineHubs,
|
|
@@ -158,6 +169,8 @@ import {
|
|
|
158
169
|
type ServerEventHub,
|
|
159
170
|
} from './server-events.ts'
|
|
160
171
|
import { createSetupRoutes } from './setup.ts'
|
|
172
|
+
import { siteSearchFor } from './site-search.ts'
|
|
173
|
+
import { TwoFactorService, USER_TWO_FACTOR_PROVIDER } from './two-factor.ts'
|
|
161
174
|
import { summariseUpgrades } from './upgrade-summary.ts'
|
|
162
175
|
import { assertViewRuntime } from './view-runtime.ts'
|
|
163
176
|
import { bundleEventProblems, createWebhookService, webhookStoreView } from './webhooks.ts'
|
|
@@ -305,15 +318,13 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
305
318
|
// A configured schema store is copied down before anything reads `schema/`, and
|
|
306
319
|
// becomes the schema directory for this boot. Everything after here — loading,
|
|
307
320
|
// validation, hashing, sync, the writer — works on files exactly as it always has.
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
config.schemaDir = config.schemaCacheDir
|
|
321
|
+
const copied = await useSchemaStore(config)
|
|
322
|
+
if (config.schemaStore)
|
|
311
323
|
schemaLog.info('Schema read from {store}: {count} files in {dir}', {
|
|
312
324
|
store: config.schemaStore.description,
|
|
313
|
-
count: copied
|
|
325
|
+
count: copied,
|
|
314
326
|
dir: config.schemaCacheDir,
|
|
315
327
|
})
|
|
316
|
-
}
|
|
317
328
|
// A node that does not own the schema still loads it — the renderer needs the
|
|
318
329
|
// property definitions — but writes none of it.
|
|
319
330
|
const schema = await bootSchema(db, owns ? config : { ...config, syncSchemaAtBoot: false })
|
|
@@ -338,10 +349,29 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
338
349
|
clientKey: (request) =>
|
|
339
350
|
peers.get(request) ?? request.headers.get('x-forwarded-for') ?? 'unknown',
|
|
340
351
|
})
|
|
352
|
+
// Story 032: a user with the authenticator on is asked for a code after the password.
|
|
353
|
+
const twoFactor = new TwoFactorService(db, {
|
|
354
|
+
secretKey: config.secretKey,
|
|
355
|
+
issuer: config.siteName,
|
|
356
|
+
})
|
|
357
|
+
let twoFactorKey: Promise<string> | undefined
|
|
341
358
|
const authRoutes = createAuthRoutes({
|
|
342
359
|
backOfficePath: paths.backOfficePath,
|
|
343
360
|
service: auth,
|
|
344
361
|
cookies,
|
|
362
|
+
twoFactor: {
|
|
363
|
+
providersFor: (userKey) => twoFactor.providers(userKey),
|
|
364
|
+
verify: (userKey, provider, code) =>
|
|
365
|
+
provider === USER_TWO_FACTOR_PROVIDER
|
|
366
|
+
? twoFactor.verify(userKey, provider, code)
|
|
367
|
+
: Promise.resolve(false),
|
|
368
|
+
secret: () => {
|
|
369
|
+
twoFactorKey ??= ensureKeyValue(db, 'crombolo/twoFactorPendingKey', () =>
|
|
370
|
+
crypto.randomUUID(),
|
|
371
|
+
)
|
|
372
|
+
return twoFactorKey
|
|
373
|
+
},
|
|
374
|
+
},
|
|
345
375
|
onAudit: (event, request) =>
|
|
346
376
|
recordAudit(db, {
|
|
347
377
|
category: 'sign-in',
|
|
@@ -356,6 +386,47 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
356
386
|
...clientAddress(request),
|
|
357
387
|
}),
|
|
358
388
|
})
|
|
389
|
+
// Story 031: backoffice users signing in through an identity provider, ending
|
|
390
|
+
// in the session the login endpoint would have set.
|
|
391
|
+
const backofficeExternal =
|
|
392
|
+
config.backofficeIdentityProviders.length === 0
|
|
393
|
+
? undefined
|
|
394
|
+
: createExternalSignIn(
|
|
395
|
+
db,
|
|
396
|
+
backofficeAudience(db, {
|
|
397
|
+
auth,
|
|
398
|
+
providers: config.backofficeIdentityProviders,
|
|
399
|
+
backOfficePath: paths.backOfficePath,
|
|
400
|
+
sessionCookie: (sessionId) =>
|
|
401
|
+
serializeCookie({
|
|
402
|
+
name: names.session,
|
|
403
|
+
value: sessionId,
|
|
404
|
+
secure: config.secureCookies,
|
|
405
|
+
maxAgeSeconds: auth.settings.refreshTokenSeconds,
|
|
406
|
+
}),
|
|
407
|
+
clientAddress: (request) => clientAddress(request).ipAddress ?? undefined,
|
|
408
|
+
onSignIn: (user, request) =>
|
|
409
|
+
recordAudit(db, {
|
|
410
|
+
category: 'sign-in',
|
|
411
|
+
action: 'SignIn',
|
|
412
|
+
source: 'request',
|
|
413
|
+
userKey: user.key,
|
|
414
|
+
userName: user.userName,
|
|
415
|
+
entityType: 'User',
|
|
416
|
+
entityKey: user.key,
|
|
417
|
+
entityName: user.userName,
|
|
418
|
+
detail: 'Through an identity provider',
|
|
419
|
+
...clientAddress(request),
|
|
420
|
+
}),
|
|
421
|
+
}),
|
|
422
|
+
{
|
|
423
|
+
applicationUrl: config.applicationUrl,
|
|
424
|
+
secureCookies: config.secureCookies,
|
|
425
|
+
log: (message) => log.warning('Backoffice sign-in: {message}', { message }),
|
|
426
|
+
resolve: (provider) =>
|
|
427
|
+
new IdentityProviderOverrides(db, config.secretKey).apply('user', provider),
|
|
428
|
+
},
|
|
429
|
+
)
|
|
359
430
|
|
|
360
431
|
/**
|
|
361
432
|
* Resolves the caller from the reference access token. The client sends
|
|
@@ -506,6 +577,25 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
506
577
|
},
|
|
507
578
|
},
|
|
508
579
|
log: bundleLog('bundle'),
|
|
580
|
+
identityProviders: createBundleIdentityProviders({
|
|
581
|
+
overrides: new IdentityProviderOverrides(db, config.secretKey),
|
|
582
|
+
members: config.memberIdentityProviders,
|
|
583
|
+
backoffice: config.backofficeIdentityProviders,
|
|
584
|
+
memberCallbackPath: MEMBER_EXTERNAL_SIGN_IN_PATH,
|
|
585
|
+
backofficeCallbackPath: BACKOFFICE_EXTERNAL_PATH,
|
|
586
|
+
groups: async (audience) =>
|
|
587
|
+
audience === 'user'
|
|
588
|
+
? (await new UserGroupRepository(db).all()).map((g) => ({
|
|
589
|
+
value: g.alias,
|
|
590
|
+
name: g.name,
|
|
591
|
+
}))
|
|
592
|
+
: (
|
|
593
|
+
await db.query<{ text: string }>(
|
|
594
|
+
'SELECT text FROM node WHERE node_object_type = ? ORDER BY text',
|
|
595
|
+
[ObjectTypes.MemberGroup],
|
|
596
|
+
)
|
|
597
|
+
).map((g) => ({ value: String(g.text), name: String(g.text) })),
|
|
598
|
+
}),
|
|
509
599
|
},
|
|
510
600
|
log: (id) => bundleLog(id),
|
|
511
601
|
})
|
|
@@ -589,6 +679,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
589
679
|
protection: () => new PublicAccessRepository(db).allRules(),
|
|
590
680
|
member: (token) => deliveryMembers.member(token),
|
|
591
681
|
security: (request, action) => deliveryMembers.handle(request, action),
|
|
682
|
+
search: (term, options) => new SiteSearch(db).query(term, options),
|
|
592
683
|
})
|
|
593
684
|
|
|
594
685
|
/** A document's latest saved version, as preview shows it, in the published tree's context. */
|
|
@@ -1593,6 +1684,8 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1593
1684
|
if (response) return response
|
|
1594
1685
|
}
|
|
1595
1686
|
|
|
1687
|
+
const externalResponse = await backofficeExternal?.handle(request, pathname)
|
|
1688
|
+
if (externalResponse) return externalResponse
|
|
1596
1689
|
const authResponse = await authRoutes.handle(request, pathname)
|
|
1597
1690
|
if (authResponse) return authResponse
|
|
1598
1691
|
|
|
@@ -1623,6 +1716,14 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1623
1716
|
// the session exists, so anything else is an open redirect at the worst
|
|
1624
1717
|
// possible moment, and `javascript:` there would run signed in.
|
|
1625
1718
|
returnUrl: localReturnUrl(url.searchParams.get('returnUrl')) ?? paths.backOfficePath,
|
|
1719
|
+
externalProviders: config.backofficeIdentityProviders.map((provider) => ({
|
|
1720
|
+
name: provider.oidc.name,
|
|
1721
|
+
url:
|
|
1722
|
+
backofficeExternal?.startUrl(
|
|
1723
|
+
provider.oidc.id,
|
|
1724
|
+
localReturnUrl(url.searchParams.get('returnUrl')) ?? paths.backOfficePath,
|
|
1725
|
+
) ?? '',
|
|
1726
|
+
})),
|
|
1626
1727
|
}),
|
|
1627
1728
|
)
|
|
1628
1729
|
}
|
|
@@ -1731,7 +1832,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1731
1832
|
if (!key) return undefined
|
|
1732
1833
|
const draft = await draftModel(key, new URL(request.url).searchParams.get('culture'))
|
|
1733
1834
|
if (!draft) return undefined
|
|
1734
|
-
return renderer.renderModel(draft.content, draft.culture,
|
|
1835
|
+
return renderer.renderModel(draft.content, draft.culture, {
|
|
1836
|
+
...(await pageRequest(request)),
|
|
1837
|
+
search: siteSearchFor(db, cache, undefined),
|
|
1838
|
+
})
|
|
1735
1839
|
}
|
|
1736
1840
|
|
|
1737
1841
|
/** What every rendered page is told about the request and the site's search defaults. */
|
|
@@ -1880,6 +1984,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1880
1984
|
member,
|
|
1881
1985
|
submission,
|
|
1882
1986
|
access: (content) => members.decide(content, member),
|
|
1987
|
+
search: siteSearchFor(db, cache, member),
|
|
1883
1988
|
...(await pageRequest(request)),
|
|
1884
1989
|
}))
|
|
1885
1990
|
const cleared: Record<string, string> = cookie
|
package/src/site-file.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A whole site in one file (story 104): every content node, media file, schema
|
|
3
|
+
* file, component, stylesheet, script and dictionary item, as one private npm
|
|
4
|
+
* package tarball — the bundle format with `scope: 'site'`. `docs/bundles.md`.
|
|
5
|
+
*
|
|
6
|
+
* Written as it goes. The media files stream from the store into the gzip, and
|
|
7
|
+
* the integrity hash is computed as each file passes, in the path order
|
|
8
|
+
* `bundleIntegrity` defines; only the small files are held. The tarball is
|
|
9
|
+
* written beside its destination and renamed into place once complete, so a
|
|
10
|
+
* failed export never leaves a file that looks whole.
|
|
11
|
+
*/
|
|
12
|
+
import { rename, rm } from 'node:fs/promises'
|
|
13
|
+
import { discoverBundles } from '@crombolo/backoffice-host'
|
|
14
|
+
import { type Db, NodeRepository, type NodeRow, ObjectTypes } from '@crombolo/data'
|
|
15
|
+
import {
|
|
16
|
+
BUNDLE_SECTIONS,
|
|
17
|
+
type BundleFile,
|
|
18
|
+
type BundlePackageInfo,
|
|
19
|
+
blobPath,
|
|
20
|
+
bundleIdFor,
|
|
21
|
+
bundlePackageJson,
|
|
22
|
+
exportBundle,
|
|
23
|
+
exportPeople,
|
|
24
|
+
MANIFEST_FILE,
|
|
25
|
+
newSeal,
|
|
26
|
+
PAYLOAD_DIR,
|
|
27
|
+
packageNameFor,
|
|
28
|
+
planStreamedBundle,
|
|
29
|
+
sanitise,
|
|
30
|
+
writeManifest,
|
|
31
|
+
} from '@crombolo/transfer'
|
|
32
|
+
import { createFileSystemPort } from './adapters/file-system.ts'
|
|
33
|
+
import { createTemplateFileStore } from './adapters/template-files.ts'
|
|
34
|
+
import { type CromboloConfig, VERSION } from './config.ts'
|
|
35
|
+
import { mediaStoreFor } from './media-files.ts'
|
|
36
|
+
import { type StoredFile, streamOf } from './media-store.ts'
|
|
37
|
+
import { siteSections } from './site-sections.ts'
|
|
38
|
+
import { TarballWriter } from './tar.ts'
|
|
39
|
+
|
|
40
|
+
export interface SiteExportOptions {
|
|
41
|
+
/** The `.tgz` to write. */
|
|
42
|
+
out: string
|
|
43
|
+
/** False for `--no-media`: the media nodes travel, their files do not. */
|
|
44
|
+
media?: boolean
|
|
45
|
+
/** Carry the latest drafts rather than what is published. */
|
|
46
|
+
drafts?: boolean
|
|
47
|
+
withMembers?: boolean
|
|
48
|
+
withUsers?: boolean
|
|
49
|
+
/** Replace every personal value before anything is written (`sanitise`). */
|
|
50
|
+
anonymise?: boolean
|
|
51
|
+
/** Seals the people; needed when any travel. */
|
|
52
|
+
passphrase?: string
|
|
53
|
+
package?: { name?: string; version?: string }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SiteExportResult {
|
|
57
|
+
out: string
|
|
58
|
+
bundleId: string
|
|
59
|
+
package: { name: string; version: string }
|
|
60
|
+
counts: Record<string, number>
|
|
61
|
+
sections: Partial<Record<keyof typeof BUNDLE_SECTIONS, number>>
|
|
62
|
+
media: { carried: number; bytes: number; missing: string[]; omitted: boolean }
|
|
63
|
+
people: { members: number; users: number }
|
|
64
|
+
/** How many distinct email addresses `--anonymise` replaced. */
|
|
65
|
+
anonymised?: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The content kinds a whole site carries, each from its tree's top. */
|
|
69
|
+
const SITE_TREES = [
|
|
70
|
+
ObjectTypes.Document,
|
|
71
|
+
ObjectTypes.Media,
|
|
72
|
+
ObjectTypes.Element,
|
|
73
|
+
ObjectTypes.DocumentBlueprint,
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
async function topLevelNodes(db: Db): Promise<NodeRow[]> {
|
|
77
|
+
const nodes = new NodeRepository(db)
|
|
78
|
+
const roots: NodeRow[] = []
|
|
79
|
+
for (const tree of SITE_TREES) {
|
|
80
|
+
for (let skip = 0; ; skip += 500) {
|
|
81
|
+
const page = await nodes.roots(tree, skip, 500)
|
|
82
|
+
roots.push(...page.items)
|
|
83
|
+
if (skip + 500 >= page.total) break
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return roots
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The server bundles the site wires, by package name and version. */
|
|
90
|
+
function requiredPackages(config: CromboloConfig): Record<string, string> {
|
|
91
|
+
if (config.bundles.length === 0) return {}
|
|
92
|
+
const wired = new Set(config.bundles.map((bundle) => bundle.id))
|
|
93
|
+
const required: Record<string, string> = {}
|
|
94
|
+
for (const found of discoverBundles(config.siteDir).bundles)
|
|
95
|
+
if (wired.has(found.id)) required[found.packageName] = found.version
|
|
96
|
+
return required
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function exportSite(
|
|
100
|
+
db: Db,
|
|
101
|
+
config: CromboloConfig,
|
|
102
|
+
options: SiteExportOptions,
|
|
103
|
+
): Promise<SiteExportResult> {
|
|
104
|
+
const withMedia = options.media !== false
|
|
105
|
+
const carryMembers = Boolean(options.withMembers)
|
|
106
|
+
// Anonymised users are removed, so there are none to carry.
|
|
107
|
+
const carryUsers = Boolean(options.withUsers) && !options.anonymise
|
|
108
|
+
if ((carryMembers || carryUsers) && !options.passphrase)
|
|
109
|
+
throw new Error('Members and users travel sealed: give a passphrase.')
|
|
110
|
+
|
|
111
|
+
const exported = await exportBundle(db, {
|
|
112
|
+
roots: await topLevelNodes(db),
|
|
113
|
+
asGiven: [],
|
|
114
|
+
descendants: true,
|
|
115
|
+
snapshot: options.drafts ? 'drafts' : 'published',
|
|
116
|
+
blueprints: true,
|
|
117
|
+
withBlobs: withMedia,
|
|
118
|
+
siteName: config.siteName,
|
|
119
|
+
nodeId: config.nodeId,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
let sections: BundleFile[] = await siteSections(
|
|
123
|
+
{
|
|
124
|
+
db,
|
|
125
|
+
componentFiles: createTemplateFileStore(config.componentsDir),
|
|
126
|
+
components: createFileSystemPort(config.componentsDir, {
|
|
127
|
+
extension: '.tsx',
|
|
128
|
+
rewrites: ['.cshtml'],
|
|
129
|
+
}),
|
|
130
|
+
stylesheets: createFileSystemPort(config.stylesheetsDir, { extension: '.css' }),
|
|
131
|
+
scripts: createFileSystemPort(config.scriptsDir, { extension: '.js' }),
|
|
132
|
+
schemaDir: config.schemaDir,
|
|
133
|
+
},
|
|
134
|
+
'all',
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
let set = exported.set
|
|
138
|
+
let people =
|
|
139
|
+
carryMembers || carryUsers
|
|
140
|
+
? await exportPeople(db, { members: carryMembers, users: carryUsers })
|
|
141
|
+
: undefined
|
|
142
|
+
let anonymised: number | undefined
|
|
143
|
+
if (options.anonymise) {
|
|
144
|
+
const dictionaryPath = `${BUNDLE_SECTIONS.dictionary}.udt`
|
|
145
|
+
const dictionary = sections.find((file) => file.path === dictionaryPath)
|
|
146
|
+
const clean = sanitise({ set, people, dictionary: dictionary?.text })
|
|
147
|
+
set = clean.set
|
|
148
|
+
people = clean.people
|
|
149
|
+
anonymised = clean.emails
|
|
150
|
+
if (dictionary && clean.dictionary !== undefined)
|
|
151
|
+
sections = sections.map((file) =>
|
|
152
|
+
file.path === dictionaryPath ? { ...file, text: clean.dictionary } : file,
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Each media file is looked up once: its size goes in the manifest now, its
|
|
157
|
+
// bytes stream into the tarball later.
|
|
158
|
+
const store = await mediaStoreFor(config)
|
|
159
|
+
const stored = new Map<string, StoredFile>()
|
|
160
|
+
const missing: string[] = []
|
|
161
|
+
if (withMedia)
|
|
162
|
+
for (const key of exported.blobKeys) {
|
|
163
|
+
const file = await store.get(key)
|
|
164
|
+
if (file) stored.set(key, file)
|
|
165
|
+
else missing.push(key)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const site = {
|
|
169
|
+
...set,
|
|
170
|
+
manifest: {
|
|
171
|
+
...set.manifest,
|
|
172
|
+
label: `${config.siteName} (whole site)`,
|
|
173
|
+
provenance: { ...set.manifest.provenance, cmsVersion: VERSION },
|
|
174
|
+
scope: 'site' as const,
|
|
175
|
+
...(withMedia ? {} : { media: 'omitted' as const }),
|
|
176
|
+
requires: requiredPackages(config),
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
const seal = people && options.passphrase ? newSeal(options.passphrase) : undefined
|
|
180
|
+
const planned = planStreamedBundle(
|
|
181
|
+
site,
|
|
182
|
+
new Map([...stored].map(([key, file]) => [key, file.size])),
|
|
183
|
+
sections,
|
|
184
|
+
{ people, seal },
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
const name = options.package?.name ?? packageNameFor(`${config.siteName}-site`)
|
|
188
|
+
const info: BundlePackageInfo = {
|
|
189
|
+
name,
|
|
190
|
+
version: options.package?.version ?? '1.0.0',
|
|
191
|
+
id: bundleIdFor(name.replace(/^@[^/]+\//, '')),
|
|
192
|
+
label: site.manifest.label,
|
|
193
|
+
private: true,
|
|
194
|
+
peerRange: `>=${VERSION}`,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const partial = `${options.out}.partial`
|
|
198
|
+
const writer = TarballWriter.create(partial)
|
|
199
|
+
const encoder = new TextEncoder()
|
|
200
|
+
const hasher = new Bun.CryptoHasher('sha256')
|
|
201
|
+
let bytes = 0
|
|
202
|
+
try {
|
|
203
|
+
const small = new Map(planned.files.map((file) => [file.path, file]))
|
|
204
|
+
const blobs = new Map(planned.blobs.map((key) => [blobPath(key), key]))
|
|
205
|
+
const paths = [...small.keys(), ...blobs.keys()].sort((a, b) => a.localeCompare(b))
|
|
206
|
+
for (const path of paths) {
|
|
207
|
+
hasher.update(path)
|
|
208
|
+
hasher.update('\0')
|
|
209
|
+
const file = small.get(path)
|
|
210
|
+
if (file) {
|
|
211
|
+
const body = file.bytes ?? encoder.encode(file.text ?? '')
|
|
212
|
+
hasher.update(body)
|
|
213
|
+
await writer.add(`${PAYLOAD_DIR}/${path}`, body)
|
|
214
|
+
} else {
|
|
215
|
+
const media = stored.get(blobs.get(path) as string) as StoredFile
|
|
216
|
+
async function* hashed() {
|
|
217
|
+
for await (const chunk of streamOf(media)) {
|
|
218
|
+
hasher.update(chunk)
|
|
219
|
+
yield chunk
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
await writer.addStream(`${PAYLOAD_DIR}/${path}`, media.size, hashed())
|
|
223
|
+
bytes += media.size
|
|
224
|
+
}
|
|
225
|
+
hasher.update('\n')
|
|
226
|
+
}
|
|
227
|
+
const manifest = { ...planned.manifest, integrity: `sha256-${hasher.digest('hex')}` }
|
|
228
|
+
await writer.add(`${PAYLOAD_DIR}/${MANIFEST_FILE}`, encoder.encode(writeManifest(manifest)))
|
|
229
|
+
await writer.add(
|
|
230
|
+
'package.json',
|
|
231
|
+
encoder.encode(`${JSON.stringify(bundlePackageJson(info), null, 2)}\n`),
|
|
232
|
+
)
|
|
233
|
+
await writer.close()
|
|
234
|
+
await rename(partial, options.out)
|
|
235
|
+
} catch (error) {
|
|
236
|
+
await writer.close().catch(() => {})
|
|
237
|
+
await rm(partial, { force: true })
|
|
238
|
+
throw error
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const counted: SiteExportResult['sections'] = {}
|
|
242
|
+
for (const file of sections) {
|
|
243
|
+
const section = (Object.keys(BUNDLE_SECTIONS) as (keyof typeof BUNDLE_SECTIONS)[]).find(
|
|
244
|
+
(s) => file.path === `${BUNDLE_SECTIONS[s]}.udt` || file.path.startsWith(`${s}/`),
|
|
245
|
+
)
|
|
246
|
+
if (section) counted[section] = (counted[section] ?? 0) + 1
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
out: options.out,
|
|
250
|
+
bundleId: site.manifest.id,
|
|
251
|
+
package: { name: info.name, version: info.version },
|
|
252
|
+
counts: { ...site.manifest.counts },
|
|
253
|
+
sections: counted,
|
|
254
|
+
media: { carried: stored.size, bytes, missing, omitted: !withMedia },
|
|
255
|
+
people: { members: people?.members.length ?? 0, users: people?.users.length ?? 0 },
|
|
256
|
+
...(anonymised !== undefined ? { anonymised } : {}),
|
|
257
|
+
}
|
|
258
|
+
}
|