@crombolo/server 0.13.2 → 0.14.4
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/api-client.ts +47 -0
- package/src/backoffice-external.ts +99 -0
- package/src/bundle-install.ts +278 -77
- package/src/bundle-sections.ts +160 -0
- package/src/bundles.ts +4 -0
- package/src/config.ts +123 -2
- package/src/database.ts +21 -8
- 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 +12 -0
- package/src/media-store-azure.ts +7 -1
- package/src/media-store-s3.ts +1 -0
- package/src/media-store.ts +25 -2
- package/src/member-auth.ts +232 -15
- package/src/ports.ts +55 -1
- package/src/remote-credentials.ts +108 -0
- package/src/remote-media.ts +91 -0
- package/src/remote.ts +793 -0
- package/src/schema-store.ts +16 -0
- package/src/schema.ts +5 -7
- package/src/server.ts +195 -11
- package/src/site-file.ts +292 -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 +164 -0
- package/src/two-factor.ts +138 -0
package/src/site-file.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
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
|
+
sealBytes,
|
|
31
|
+
writeManifest,
|
|
32
|
+
} from '@crombolo/transfer'
|
|
33
|
+
import { createFileSystemPort } from './adapters/file-system.ts'
|
|
34
|
+
import { createTemplateFileStore } from './adapters/template-files.ts'
|
|
35
|
+
import { type CromboloConfig, VERSION } from './config.ts'
|
|
36
|
+
import { mediaStoreFor } from './media-files.ts'
|
|
37
|
+
import { type StoredFile, streamOf } from './media-store.ts'
|
|
38
|
+
import { siteSections } from './site-sections.ts'
|
|
39
|
+
import { TarballWriter } from './tar.ts'
|
|
40
|
+
|
|
41
|
+
export interface SiteExportOptions {
|
|
42
|
+
/** The `.tgz` to write. */
|
|
43
|
+
out: string
|
|
44
|
+
/**
|
|
45
|
+
* False for `--no-media`: the media nodes travel, their files do not.
|
|
46
|
+
* `referenced`: likewise, for a remote and its CLI to move the files one at a
|
|
47
|
+
* time (story 107).
|
|
48
|
+
*/
|
|
49
|
+
media?: boolean | 'referenced'
|
|
50
|
+
/** Carry the latest drafts rather than what is published. */
|
|
51
|
+
drafts?: boolean
|
|
52
|
+
withMembers?: boolean
|
|
53
|
+
withUsers?: boolean
|
|
54
|
+
/** Replace every personal value before anything is written (`sanitise`). */
|
|
55
|
+
anonymise?: boolean
|
|
56
|
+
/**
|
|
57
|
+
* Seal the content data — property values and media files — as well as any
|
|
58
|
+
* people, under `passphrase`: the bundle rule, metadata readable and content
|
|
59
|
+
* not (story 107). Each media file is sealed whole, one at a time.
|
|
60
|
+
*/
|
|
61
|
+
sealContent?: boolean
|
|
62
|
+
/** Seals the people, and the content with `sealContent`; needed when either travels sealed. */
|
|
63
|
+
passphrase?: string
|
|
64
|
+
package?: { name?: string; version?: string }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface SiteExportResult {
|
|
68
|
+
out: string
|
|
69
|
+
bundleId: string
|
|
70
|
+
package: { name: string; version: string }
|
|
71
|
+
counts: Record<string, number>
|
|
72
|
+
sections: Partial<Record<keyof typeof BUNDLE_SECTIONS, number>>
|
|
73
|
+
media: { carried: number; bytes: number; missing: string[]; omitted: boolean }
|
|
74
|
+
people: { members: number; users: number }
|
|
75
|
+
/** How many distinct email addresses `--anonymise` replaced. */
|
|
76
|
+
anonymised?: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The content kinds a whole site carries, each from its tree's top. */
|
|
80
|
+
const SITE_TREES = [
|
|
81
|
+
ObjectTypes.Document,
|
|
82
|
+
ObjectTypes.Media,
|
|
83
|
+
ObjectTypes.Element,
|
|
84
|
+
ObjectTypes.DocumentBlueprint,
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
async function topLevelNodes(db: Db): Promise<NodeRow[]> {
|
|
88
|
+
const nodes = new NodeRepository(db)
|
|
89
|
+
const roots: NodeRow[] = []
|
|
90
|
+
for (const tree of SITE_TREES) {
|
|
91
|
+
for (let skip = 0; ; skip += 500) {
|
|
92
|
+
const page = await nodes.roots(tree, skip, 500)
|
|
93
|
+
roots.push(...page.items)
|
|
94
|
+
if (skip + 500 >= page.total) break
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return roots
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The server bundles the site wires, by package name and version. */
|
|
101
|
+
function requiredPackages(config: CromboloConfig): Record<string, string> {
|
|
102
|
+
if (config.bundles.length === 0) return {}
|
|
103
|
+
const wired = new Set(config.bundles.map((bundle) => bundle.id))
|
|
104
|
+
const required: Record<string, string> = {}
|
|
105
|
+
for (const found of discoverBundles(config.siteDir).bundles)
|
|
106
|
+
if (wired.has(found.id)) required[found.packageName] = found.version
|
|
107
|
+
return required
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function exportSite(
|
|
111
|
+
db: Db,
|
|
112
|
+
config: CromboloConfig,
|
|
113
|
+
options: SiteExportOptions,
|
|
114
|
+
): Promise<SiteExportResult> {
|
|
115
|
+
const withMedia = options.media !== false && options.media !== 'referenced'
|
|
116
|
+
const carryMembers = Boolean(options.withMembers)
|
|
117
|
+
// Anonymised users are removed, so there are none to carry.
|
|
118
|
+
const carryUsers = Boolean(options.withUsers) && !options.anonymise
|
|
119
|
+
if ((carryMembers || carryUsers) && !options.passphrase)
|
|
120
|
+
throw new Error('Members and users travel sealed: give a passphrase.')
|
|
121
|
+
if (options.sealContent && !options.passphrase)
|
|
122
|
+
throw new Error('Sealed content needs a passphrase.')
|
|
123
|
+
|
|
124
|
+
const exported = await exportBundle(db, {
|
|
125
|
+
roots: await topLevelNodes(db),
|
|
126
|
+
asGiven: [],
|
|
127
|
+
descendants: true,
|
|
128
|
+
snapshot: options.drafts ? 'drafts' : 'published',
|
|
129
|
+
blueprints: true,
|
|
130
|
+
withBlobs: withMedia,
|
|
131
|
+
siteName: config.siteName,
|
|
132
|
+
nodeId: config.nodeId,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
let sections: BundleFile[] = await siteSections(
|
|
136
|
+
{
|
|
137
|
+
db,
|
|
138
|
+
componentFiles: createTemplateFileStore(config.componentsDir),
|
|
139
|
+
components: createFileSystemPort(config.componentsDir, {
|
|
140
|
+
extension: '.tsx',
|
|
141
|
+
rewrites: ['.cshtml'],
|
|
142
|
+
}),
|
|
143
|
+
stylesheets: createFileSystemPort(config.stylesheetsDir, { extension: '.css' }),
|
|
144
|
+
scripts: createFileSystemPort(config.scriptsDir, { extension: '.js' }),
|
|
145
|
+
schemaDir: config.schemaDir,
|
|
146
|
+
},
|
|
147
|
+
'all',
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
let set = exported.set
|
|
151
|
+
let people =
|
|
152
|
+
carryMembers || carryUsers
|
|
153
|
+
? await exportPeople(db, { members: carryMembers, users: carryUsers })
|
|
154
|
+
: undefined
|
|
155
|
+
let anonymised: number | undefined
|
|
156
|
+
if (options.anonymise) {
|
|
157
|
+
const dictionaryPath = `${BUNDLE_SECTIONS.dictionary}.udt`
|
|
158
|
+
const dictionary = sections.find((file) => file.path === dictionaryPath)
|
|
159
|
+
const clean = sanitise({ set, people, dictionary: dictionary?.text })
|
|
160
|
+
set = clean.set
|
|
161
|
+
people = clean.people
|
|
162
|
+
anonymised = clean.emails
|
|
163
|
+
if (dictionary && clean.dictionary !== undefined)
|
|
164
|
+
sections = sections.map((file) =>
|
|
165
|
+
file.path === dictionaryPath ? { ...file, text: clean.dictionary } : file,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Each media file is looked up once: its size goes in the manifest now, its
|
|
170
|
+
// bytes stream into the tarball later.
|
|
171
|
+
const store = await mediaStoreFor(config)
|
|
172
|
+
const stored = new Map<string, StoredFile>()
|
|
173
|
+
const missing: string[] = []
|
|
174
|
+
// Referenced, each file is named with its size and etag, so the far side can
|
|
175
|
+
// fetch it — or skip it, holding one with the same etag already.
|
|
176
|
+
const described = new Map<string, { size: number; etag: string }>()
|
|
177
|
+
if (withMedia || options.media === 'referenced')
|
|
178
|
+
for (const key of exported.blobKeys) {
|
|
179
|
+
const file = await store.get(key)
|
|
180
|
+
if (!file) missing.push(key)
|
|
181
|
+
else if (withMedia) stored.set(key, file)
|
|
182
|
+
else described.set(key, { size: file.size, etag: file.etag })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const site = {
|
|
186
|
+
...set,
|
|
187
|
+
manifest: {
|
|
188
|
+
...set.manifest,
|
|
189
|
+
blobs: set.manifest.blobs.map((blob) => {
|
|
190
|
+
const found = described.get(blob.key)
|
|
191
|
+
return found ? { ...blob, size: found.size, etag: found.etag } : blob
|
|
192
|
+
}),
|
|
193
|
+
label: `${config.siteName} (whole site)`,
|
|
194
|
+
provenance: { ...set.manifest.provenance, cmsVersion: VERSION },
|
|
195
|
+
scope: 'site' as const,
|
|
196
|
+
...(withMedia
|
|
197
|
+
? {}
|
|
198
|
+
: {
|
|
199
|
+
media: options.media === 'referenced' ? ('referenced' as const) : ('omitted' as const),
|
|
200
|
+
}),
|
|
201
|
+
requires: requiredPackages(config),
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
const seal =
|
|
205
|
+
(people || options.sealContent) && options.passphrase ? newSeal(options.passphrase) : undefined
|
|
206
|
+
const planned = planStreamedBundle(
|
|
207
|
+
site,
|
|
208
|
+
new Map([...stored].map(([key, file]) => [key, file.size])),
|
|
209
|
+
sections,
|
|
210
|
+
{ people, seal, content: options.sealContent ? 'sealed' : 'open' },
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
const name = options.package?.name ?? packageNameFor(`${config.siteName}-site`)
|
|
214
|
+
const info: BundlePackageInfo = {
|
|
215
|
+
name,
|
|
216
|
+
version: options.package?.version ?? '1.0.0',
|
|
217
|
+
id: bundleIdFor(name.replace(/^@[^/]+\//, '')),
|
|
218
|
+
label: site.manifest.label,
|
|
219
|
+
private: true,
|
|
220
|
+
peerRange: `>=${VERSION}`,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const partial = `${options.out}.partial`
|
|
224
|
+
const writer = TarballWriter.create(partial)
|
|
225
|
+
const encoder = new TextEncoder()
|
|
226
|
+
const hasher = new Bun.CryptoHasher('sha256')
|
|
227
|
+
let bytes = 0
|
|
228
|
+
try {
|
|
229
|
+
const small = new Map(planned.files.map((file) => [file.path, file]))
|
|
230
|
+
const blobs = new Map(planned.blobs.map((key) => [blobPath(key), key]))
|
|
231
|
+
const paths = [...small.keys(), ...blobs.keys()].sort((a, b) => a.localeCompare(b))
|
|
232
|
+
for (const path of paths) {
|
|
233
|
+
hasher.update(path)
|
|
234
|
+
hasher.update('\0')
|
|
235
|
+
const file = small.get(path)
|
|
236
|
+
if (file) {
|
|
237
|
+
const body = file.bytes ?? encoder.encode(file.text ?? '')
|
|
238
|
+
hasher.update(body)
|
|
239
|
+
await writer.add(`${PAYLOAD_DIR}/${path}`, body)
|
|
240
|
+
} else {
|
|
241
|
+
const media = stored.get(blobs.get(path) as string) as StoredFile
|
|
242
|
+
if (planned.sealing && seal) {
|
|
243
|
+
const sealed = sealBytes(seal.key, path, await media.bytes())
|
|
244
|
+
hasher.update(sealed)
|
|
245
|
+
hasher.update('\n')
|
|
246
|
+
await writer.add(`${PAYLOAD_DIR}/${path}`, sealed)
|
|
247
|
+
bytes += media.size
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
async function* hashed() {
|
|
251
|
+
for await (const chunk of streamOf(media)) {
|
|
252
|
+
hasher.update(chunk)
|
|
253
|
+
yield chunk
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
await writer.addStream(`${PAYLOAD_DIR}/${path}`, media.size, hashed())
|
|
257
|
+
bytes += media.size
|
|
258
|
+
}
|
|
259
|
+
hasher.update('\n')
|
|
260
|
+
}
|
|
261
|
+
const manifest = { ...planned.manifest, integrity: `sha256-${hasher.digest('hex')}` }
|
|
262
|
+
await writer.add(`${PAYLOAD_DIR}/${MANIFEST_FILE}`, encoder.encode(writeManifest(manifest)))
|
|
263
|
+
await writer.add(
|
|
264
|
+
'package.json',
|
|
265
|
+
encoder.encode(`${JSON.stringify(bundlePackageJson(info), null, 2)}\n`),
|
|
266
|
+
)
|
|
267
|
+
await writer.close()
|
|
268
|
+
await rename(partial, options.out)
|
|
269
|
+
} catch (error) {
|
|
270
|
+
await writer.abort()
|
|
271
|
+
await rm(partial, { force: true })
|
|
272
|
+
throw error
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const counted: SiteExportResult['sections'] = {}
|
|
276
|
+
for (const file of sections) {
|
|
277
|
+
const section = (Object.keys(BUNDLE_SECTIONS) as (keyof typeof BUNDLE_SECTIONS)[]).find(
|
|
278
|
+
(s) => file.path === `${BUNDLE_SECTIONS[s]}.udt` || file.path.startsWith(`${s}/`),
|
|
279
|
+
)
|
|
280
|
+
if (section) counted[section] = (counted[section] ?? 0) + 1
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
out: options.out,
|
|
284
|
+
bundleId: site.manifest.id,
|
|
285
|
+
package: { name: info.name, version: info.version },
|
|
286
|
+
counts: { ...site.manifest.counts },
|
|
287
|
+
sections: counted,
|
|
288
|
+
media: { carried: stored.size, bytes, missing, omitted: !withMedia },
|
|
289
|
+
people: { members: people?.members.length ?? 0, users: people?.users.length ?? 0 },
|
|
290
|
+
...(anonymised !== undefined ? { anonymised } : {}),
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replacing a site with a whole-site file (story 105): what has to be true
|
|
3
|
+
* before it starts, and what the site has that the file does not.
|
|
4
|
+
*
|
|
5
|
+
* `--replace` recycles content and media the file does not carry, so a revert
|
|
6
|
+
* brings them back, and keeps the site's people. `--replace-all` deletes them,
|
|
7
|
+
* and the members and users the file does not carry too.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from 'node:fs'
|
|
10
|
+
import { join, sep } from 'node:path'
|
|
11
|
+
import { discoverBundles } from '@crombolo/backoffice-host'
|
|
12
|
+
import {
|
|
13
|
+
currentSchemaState,
|
|
14
|
+
type Db,
|
|
15
|
+
DocumentRepository,
|
|
16
|
+
MemberRepository,
|
|
17
|
+
ObjectTypes,
|
|
18
|
+
TransferRunRepository,
|
|
19
|
+
UserRepository,
|
|
20
|
+
} from '@crombolo/data'
|
|
21
|
+
import type { BundleManifest, BundlePeople, ContentSet } from '@crombolo/transfer'
|
|
22
|
+
|
|
23
|
+
export type ReplaceMode = 'replace' | 'replace-all'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The server bundles a file needs and this site lacks, or has older, each
|
|
27
|
+
* named with the version wanted.
|
|
28
|
+
*/
|
|
29
|
+
export function requiresProblems(manifest: BundleManifest, siteDir: string): string[] {
|
|
30
|
+
const required = Object.entries(manifest.requires ?? {})
|
|
31
|
+
if (required.length === 0) return []
|
|
32
|
+
const installed = new Map(
|
|
33
|
+
discoverBundles(siteDir).bundles.map((found) => [found.packageName, found.version]),
|
|
34
|
+
)
|
|
35
|
+
const problems: string[] = []
|
|
36
|
+
for (const [name, version] of required) {
|
|
37
|
+
const here = installed.get(name)
|
|
38
|
+
if (!here) problems.push(`the file needs ${name}@${version}, which this site does not have`)
|
|
39
|
+
else if (Bun.semver.order(here, version) < 0)
|
|
40
|
+
problems.push(`the file needs ${name}@${version}, and this site has ${here}`)
|
|
41
|
+
}
|
|
42
|
+
return problems
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether the file's people include someone who can administer the back office. */
|
|
46
|
+
export function carriesAdministrator(people: BundlePeople | undefined): boolean {
|
|
47
|
+
return (people?.users ?? []).some((user) => !user.disabled && user.groupAliases.includes('admin'))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const PRUNED_KINDS = [
|
|
51
|
+
{ objectType: ObjectTypes.Document, kind: 'document' },
|
|
52
|
+
{ objectType: ObjectTypes.Media, kind: 'media' },
|
|
53
|
+
{ objectType: ObjectTypes.Element, kind: 'element' },
|
|
54
|
+
{ objectType: ObjectTypes.DocumentBlueprint, kind: 'blueprint' },
|
|
55
|
+
] as const
|
|
56
|
+
|
|
57
|
+
export interface PrunedNode {
|
|
58
|
+
key: string
|
|
59
|
+
kind: (typeof PRUNED_KINDS)[number]['kind']
|
|
60
|
+
head: number | undefined
|
|
61
|
+
published: number | undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Takes out what the site has and the file does not: the topmost of each such
|
|
66
|
+
* branch, recycled or deleted, inside the caller's transaction. Answers what
|
|
67
|
+
* was recycled, for the run to record so its revert can bring it back.
|
|
68
|
+
*/
|
|
69
|
+
export async function pruneSite(
|
|
70
|
+
tx: Db,
|
|
71
|
+
set: ContentSet,
|
|
72
|
+
mode: ReplaceMode,
|
|
73
|
+
nodeId: string,
|
|
74
|
+
): Promise<PrunedNode[]> {
|
|
75
|
+
const inFile = new Set(set.nodes.map((node) => node.key.toLowerCase()))
|
|
76
|
+
const state = await currentSchemaState(tx)
|
|
77
|
+
const nodeState = { version: state.version, revision: state.revision }
|
|
78
|
+
const pruned: PrunedNode[] = []
|
|
79
|
+
for (const { objectType, kind } of PRUNED_KINDS) {
|
|
80
|
+
const rows = await tx.query<{ id: number; unique_id: string; parent_id: number }>(
|
|
81
|
+
'SELECT id, unique_id, parent_id FROM node WHERE node_object_type = ? AND trashed = ?',
|
|
82
|
+
[objectType, tx.dialect.boolValue(false)],
|
|
83
|
+
)
|
|
84
|
+
const leaving = rows.filter((row) => !inFile.has(String(row.unique_id).toLowerCase()))
|
|
85
|
+
const leavingIds = new Set(leaving.map((row) => Number(row.id)))
|
|
86
|
+
// The top of each branch: recycling or deleting it takes its children too.
|
|
87
|
+
const tops = leaving.filter((row) => !leavingIds.has(Number(row.parent_id)))
|
|
88
|
+
const repo = new DocumentRepository(tx, { kind, nodeState, nodeId, inTreeLock: true })
|
|
89
|
+
for (const row of tops) {
|
|
90
|
+
const key = String(row.unique_id)
|
|
91
|
+
if (mode === 'replace-all') {
|
|
92
|
+
await repo.delete(key)
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
const markers = await repo.eventMarkers(key)
|
|
96
|
+
await repo.moveToRecycleBin(key)
|
|
97
|
+
pruned.push({ key, kind, head: markers?.head, published: markers?.published })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return pruned
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Members and users the file does not carry, deleted: `--replace-all` only. */
|
|
104
|
+
export async function prunePeople(tx: Db, people: BundlePeople | undefined): Promise<void> {
|
|
105
|
+
const members = new Set((people?.members ?? []).map((m) => m.key.toLowerCase()))
|
|
106
|
+
const users = new Set((people?.users ?? []).map((u) => u.key.toLowerCase()))
|
|
107
|
+
const state = await currentSchemaState(tx)
|
|
108
|
+
const memberRepo = new MemberRepository(tx, {
|
|
109
|
+
nodeState: { version: state.version, revision: state.revision },
|
|
110
|
+
})
|
|
111
|
+
const memberRows = await tx.query<{ unique_id: string }>(
|
|
112
|
+
'SELECT unique_id FROM node WHERE node_object_type = ?',
|
|
113
|
+
[ObjectTypes.Member],
|
|
114
|
+
)
|
|
115
|
+
for (const row of memberRows)
|
|
116
|
+
if (!members.has(String(row.unique_id).toLowerCase()))
|
|
117
|
+
await memberRepo.delete(String(row.unique_id))
|
|
118
|
+
const userRepo = new UserRepository(tx)
|
|
119
|
+
for (const row of await tx.query<{ key: string }>('SELECT key FROM user_account'))
|
|
120
|
+
if (!users.has(String(row.key).toLowerCase())) await userRepo.delete(String(row.key))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Records what a replace recycled on its run, so `content revert` brings it back. */
|
|
124
|
+
export async function recordPruned(tx: Db, runId: string, pruned: PrunedNode[]): Promise<void> {
|
|
125
|
+
const runs = new TransferRunRepository(tx)
|
|
126
|
+
for (const node of pruned)
|
|
127
|
+
await runs.record({
|
|
128
|
+
runId,
|
|
129
|
+
nodeKey: node.key,
|
|
130
|
+
kind: node.kind,
|
|
131
|
+
action: 'recycle',
|
|
132
|
+
beforeEventId: node.head ?? null,
|
|
133
|
+
beforePublishedEventId: node.published ?? null,
|
|
134
|
+
eventId: null,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ReplaceSummary {
|
|
139
|
+
/** What the site has that the file does not, by kind: recycled, or deleted. */
|
|
140
|
+
leaving: Record<string, number>
|
|
141
|
+
/** Files in the site's folders that the file does not carry. */
|
|
142
|
+
filesRemoved: string[]
|
|
143
|
+
/** Members and users `--replace-all` would delete. */
|
|
144
|
+
people: { members: number; users: number }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Read-only: what replacing this site with the file would take out. */
|
|
148
|
+
export async function replaceSummary(
|
|
149
|
+
db: Db,
|
|
150
|
+
set: ContentSet,
|
|
151
|
+
carriedPaths: ReadonlySet<string>,
|
|
152
|
+
folders: ReadonlyArray<{ section: string; dir: string }>,
|
|
153
|
+
mode: ReplaceMode,
|
|
154
|
+
): Promise<ReplaceSummary> {
|
|
155
|
+
const inFile = new Set(set.nodes.map((node) => node.key.toLowerCase()))
|
|
156
|
+
const leaving: Record<string, number> = {}
|
|
157
|
+
for (const { objectType, kind } of PRUNED_KINDS) {
|
|
158
|
+
const rows = await db.query<{ unique_id: string }>(
|
|
159
|
+
'SELECT unique_id FROM node WHERE node_object_type = ? AND trashed = ?',
|
|
160
|
+
[objectType, db.dialect.boolValue(false)],
|
|
161
|
+
)
|
|
162
|
+
const count = rows.filter((row) => !inFile.has(String(row.unique_id).toLowerCase())).length
|
|
163
|
+
if (count > 0) leaving[kind] = count
|
|
164
|
+
}
|
|
165
|
+
const filesRemoved: string[] = []
|
|
166
|
+
for (const { section, dir } of folders) {
|
|
167
|
+
if (!existsSync(dir)) continue
|
|
168
|
+
for (const path of new Bun.Glob('**/*').scanSync({ cwd: dir, onlyFiles: true })) {
|
|
169
|
+
const bundlePath = `${section}/${path.split(sep).join('/')}`
|
|
170
|
+
if (!carriedPaths.has(bundlePath)) filesRemoved.push(join(dir, path))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
let people = { members: 0, users: 0 }
|
|
174
|
+
if (mode === 'replace-all') {
|
|
175
|
+
const members = new Set((set.people?.members ?? []).map((m) => m.key.toLowerCase()))
|
|
176
|
+
const users = new Set((set.people?.users ?? []).map((u) => u.key.toLowerCase()))
|
|
177
|
+
const memberRows = await db.query<{ unique_id: string }>(
|
|
178
|
+
'SELECT unique_id FROM node WHERE node_object_type = ?',
|
|
179
|
+
[ObjectTypes.Member],
|
|
180
|
+
)
|
|
181
|
+
const userRows = await db.query<{ key: string }>('SELECT key FROM user_account')
|
|
182
|
+
people = {
|
|
183
|
+
members: memberRows.filter((r) => !members.has(String(r.unique_id).toLowerCase())).length,
|
|
184
|
+
users: userRows.filter((r) => !users.has(String(r.key).toLowerCase())).length,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { leaving, filesRemoved, people }
|
|
188
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site search for a render (story 140): the published-only index, narrowed to
|
|
3
|
+
* what the person asking may open, each hit read back from the published cache
|
|
4
|
+
* so a page unpublished since it was indexed is never shown. docs/search.md.
|
|
5
|
+
*/
|
|
6
|
+
import { branchAccess } from '@crombolo/core'
|
|
7
|
+
import { type Db, PublicAccessRepository, SiteSearch } from '@crombolo/data'
|
|
8
|
+
import {
|
|
9
|
+
excerpt,
|
|
10
|
+
type PublishedCache,
|
|
11
|
+
type RequestMember,
|
|
12
|
+
type SearchResult,
|
|
13
|
+
type SiteSearchPort,
|
|
14
|
+
} from '@crombolo/render'
|
|
15
|
+
|
|
16
|
+
/** The search `search()` calls during one render, for `member` or for nobody signed in. */
|
|
17
|
+
export function siteSearchFor(
|
|
18
|
+
db: Db,
|
|
19
|
+
cache: PublishedCache,
|
|
20
|
+
member: RequestMember | undefined,
|
|
21
|
+
): SiteSearchPort {
|
|
22
|
+
return async (query, options) => {
|
|
23
|
+
const rules = await new PublicAccessRepository(db).allRules()
|
|
24
|
+
const access = branchAccess(
|
|
25
|
+
rules,
|
|
26
|
+
member ? { username: member.username, groups: member.roles } : undefined,
|
|
27
|
+
)
|
|
28
|
+
const culture = options.culture ?? (await cache.defaultCulture())
|
|
29
|
+
const hits = await new SiteSearch(db).query(query, {
|
|
30
|
+
culture: culture ?? '',
|
|
31
|
+
types: options.types,
|
|
32
|
+
skip: options.skip,
|
|
33
|
+
take: options.take,
|
|
34
|
+
access,
|
|
35
|
+
})
|
|
36
|
+
const nav = await cache.navigation(culture)
|
|
37
|
+
const items: SearchResult[] = []
|
|
38
|
+
for (const hit of hits.items) {
|
|
39
|
+
const content = nav.byKey(hit.key)
|
|
40
|
+
if (!content) continue
|
|
41
|
+
items.push({
|
|
42
|
+
content,
|
|
43
|
+
url: content.url,
|
|
44
|
+
name: content.name,
|
|
45
|
+
score: hit.score,
|
|
46
|
+
excerpt: excerpt(hit.text, query),
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
return { total: hits.total, items }
|
|
50
|
+
}
|
|
51
|
+
}
|