@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.
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The sections a bundle carries beyond its content — schema, components,
3
+ * styles, scripts and the dictionary — gathered from a site. One place, so the
4
+ * backoffice's Packages builder (picked items) and `crombolo export --site`
5
+ * (everything) compose a bundle the same way. `docs/bundles.md`.
6
+ */
7
+ import { readdir, readFile } from 'node:fs/promises'
8
+ import { join, relative, sep } from 'node:path'
9
+ import type { FileSystemPort } from '@crombolo/api-management'
10
+ import {
11
+ type ComponentFileStore,
12
+ ComponentRepository,
13
+ currentSchemaState,
14
+ type Db,
15
+ DictionaryRepository,
16
+ } from '@crombolo/data'
17
+ import {
18
+ exportSchemaSet,
19
+ fileNameFor,
20
+ type SchemaSet,
21
+ writeContentType,
22
+ writeDataType,
23
+ writeLanguages,
24
+ writeMediaType,
25
+ writeSchemaVersion,
26
+ } from '@crombolo/schema'
27
+ import { BUNDLE_SECTIONS, type BundleFile, type BundleSection } from '@crombolo/transfer'
28
+ import { dictionaryToUdt, writeDictionaryUdt } from './dictionary-transfer.ts'
29
+
30
+ export interface SiteSectionSources {
31
+ db: Db
32
+ /** Reads a template's view, which lives on disk rather than in the row. */
33
+ componentFiles: ComponentFileStore
34
+ components?: FileSystemPort
35
+ stylesheets?: FileSystemPort
36
+ scripts?: FileSystemPort
37
+ /** The schema directory, read whole for `'all'`: the files are the schema. */
38
+ schemaDir?: string
39
+ }
40
+
41
+ /** What a Packages definition picks, by key or alias as the backoffice sends them. */
42
+ export interface SectionPicks {
43
+ contentTypes: readonly string[]
44
+ mediaTypes: readonly string[]
45
+ dataTypes: readonly string[]
46
+ languages: readonly string[]
47
+ /** Template aliases and paths in the components tree, in one list. */
48
+ components: readonly string[]
49
+ stylesheets: readonly string[]
50
+ scripts: readonly string[]
51
+ dictionaryItems: readonly string[]
52
+ }
53
+
54
+ /** Matches a selection against a type's key or its alias, since the UI sends either. */
55
+ const picks = (selected: readonly string[]) => {
56
+ const wanted = new Set(selected.map((value) => value.trim().toLowerCase()))
57
+ return (type: { key?: string; alias: string }): boolean =>
58
+ wanted.has(type.alias.toLowerCase()) ||
59
+ (type.key !== undefined && wanted.has(type.key.toLowerCase()))
60
+ }
61
+
62
+ /** The selected slice of the site's schema, as the files `schema/` would hold. */
63
+ function schemaEntries(set: SchemaSet, from: SectionPicks): BundleFile[] {
64
+ const entries: BundleFile[] = []
65
+ const put = (path: string, text: string) =>
66
+ entries.push({ path: `${BUNDLE_SECTIONS.schema}/${path}`, text })
67
+
68
+ const contentTypes = set.contentTypes.filter(picks(from.contentTypes))
69
+ const mediaTypes = (set.mediaTypes ?? []).filter(picks(from.mediaTypes))
70
+ // Member types have no picker in the builder, and the contract's definition
71
+ // has no field for them, so a picked bundle never carries one.
72
+ const dataTypes = set.dataTypes.filter(picks(from.dataTypes))
73
+ const languages = set.languages.filter((language) =>
74
+ from.languages.some((pick) => pick.toLowerCase() === language.iso.toLowerCase()),
75
+ )
76
+
77
+ if (
78
+ contentTypes.length === 0 &&
79
+ mediaTypes.length === 0 &&
80
+ dataTypes.length === 0 &&
81
+ languages.length === 0
82
+ )
83
+ return entries
84
+
85
+ put('schema.toml', writeSchemaVersion(set.version))
86
+ for (const type of contentTypes)
87
+ put(join('content-types', fileNameFor(type.alias)), writeContentType(type))
88
+ for (const type of mediaTypes)
89
+ put(join('media-types', fileNameFor(type.alias)), writeMediaType(type))
90
+ for (const type of dataTypes)
91
+ put(join('data-types', fileNameFor(type.alias)), writeDataType(type))
92
+ if (languages.length > 0) put('languages.toml', writeLanguages(languages))
93
+ return entries
94
+ }
95
+
96
+ /** Every `.toml` under the schema directory, as it stands: the whole site's schema. */
97
+ async function wholeSchema(dir: string | undefined): Promise<BundleFile[]> {
98
+ if (!dir) return []
99
+ const files: BundleFile[] = []
100
+ const walk = async (at: string): Promise<void> => {
101
+ let entries: import('node:fs').Dirent[]
102
+ try {
103
+ entries = await readdir(at, { withFileTypes: true })
104
+ } catch {
105
+ return
106
+ }
107
+ for (const entry of entries) {
108
+ const path = join(at, entry.name)
109
+ if (entry.isDirectory()) await walk(path)
110
+ else if (entry.name.toLowerCase().endsWith('.toml'))
111
+ files.push({
112
+ path: `${BUNDLE_SECTIONS.schema}/${relative(dir, path).split(sep).join('/')}`,
113
+ text: await readFile(path, 'utf8'),
114
+ })
115
+ }
116
+ }
117
+ await walk(dir)
118
+ return files
119
+ }
120
+
121
+ /** Every file a file area holds, by its path within the area. */
122
+ async function everyPath(port: FileSystemPort | undefined): Promise<string[]> {
123
+ if (!port) return []
124
+ const paths: string[] = []
125
+ const walk = async (parent: string | null): Promise<void> => {
126
+ for (const entry of (await port.children(parent)) ?? []) {
127
+ if (entry.isFolder) await walk(entry.path)
128
+ else paths.push(entry.path)
129
+ }
130
+ }
131
+ await walk(null)
132
+ return paths
133
+ }
134
+
135
+ /**
136
+ * The files a selection of file-system paths resolves to, under one section.
137
+ *
138
+ * The section is a logical name — `styles`, not `css` — because a site holds
139
+ * these wherever it is configured to, and the importer is what maps a section
140
+ * onto a directory.
141
+ */
142
+ async function fileEntries(
143
+ port: FileSystemPort | undefined,
144
+ paths: readonly string[],
145
+ section: BundleSection,
146
+ ): Promise<BundleFile[]> {
147
+ if (!port) return []
148
+ const files: BundleFile[] = []
149
+ for (const path of paths) {
150
+ const file = await port.read(path)
151
+ if (!file) continue
152
+ const within = file.path.replace(/^\/+/, '')
153
+ files.push({ path: `${BUNDLE_SECTIONS[section]}/${within}`, text: file.content })
154
+ }
155
+ return files
156
+ }
157
+
158
+ async function componentEntries(
159
+ sources: SiteSectionSources,
160
+ keys: readonly string[],
161
+ ): Promise<BundleFile[]> {
162
+ if (keys.length === 0) return []
163
+ const templates = new ComponentRepository(sources.db, sources.componentFiles)
164
+ const wanted = new Set(keys.map((key) => key.trim().toLowerCase()))
165
+ const files: BundleFile[] = []
166
+ for (const template of await templates.all()) {
167
+ if (!wanted.has(template.key.toLowerCase()) && !wanted.has(template.alias.toLowerCase()))
168
+ continue
169
+ files.push({
170
+ path: `${BUNDLE_SECTIONS.components}/${template.alias}.tsx`,
171
+ text: template.content ?? '',
172
+ })
173
+ }
174
+ return files
175
+ }
176
+
177
+ /**
178
+ * The components a bundle carries, from one list that holds two kinds of name.
179
+ *
180
+ * The contract has `templates` and `partialViews`; this side has one list, so a
181
+ * picked component arrives either as a template alias — read through the alias
182
+ * — or as a path in the components tree. Both are tried and the results are
183
+ * keyed by path, so a component picked both ways is carried once.
184
+ */
185
+ async function componentSection(
186
+ sources: SiteSectionSources,
187
+ picked: readonly string[],
188
+ ): Promise<BundleFile[]> {
189
+ const byPath = new Map<string, BundleFile>()
190
+ for (const file of await componentEntries(sources, picked)) byPath.set(file.path, file)
191
+ for (const file of await fileEntries(sources.components, picked, 'components'))
192
+ byPath.set(file.path, file)
193
+ return [...byPath.values()]
194
+ }
195
+
196
+ /** The dictionary as one `.udt`: the picked items, or every root item when none are named. */
197
+ async function dictionaryEntries(db: Db, keys: readonly string[] | 'all'): Promise<BundleFile[]> {
198
+ const repo = new DictionaryRepository(db)
199
+ const items =
200
+ keys === 'all'
201
+ ? await dictionaryToUdt(repo)
202
+ : (await Promise.all(keys.map((key) => dictionaryToUdt(repo, key)))).flat()
203
+ if (items.length === 0) return []
204
+ return [{ path: `${BUNDLE_SECTIONS.dictionary}.udt`, text: writeDictionaryUdt(items) }]
205
+ }
206
+
207
+ /** The picked sections, or with `'all'` every one of them in full. */
208
+ export async function siteSections(
209
+ sources: SiteSectionSources,
210
+ selection: SectionPicks | 'all',
211
+ ): Promise<BundleFile[]> {
212
+ if (selection === 'all') {
213
+ return [
214
+ ...(await wholeSchema(sources.schemaDir)),
215
+ ...(await fileEntries(sources.components, await everyPath(sources.components), 'components')),
216
+ ...(await fileEntries(sources.stylesheets, await everyPath(sources.stylesheets), 'styles')),
217
+ ...(await fileEntries(sources.scripts, await everyPath(sources.scripts), 'scripts')),
218
+ ...(await dictionaryEntries(sources.db, 'all')),
219
+ ]
220
+ }
221
+ // The schema version the site is actually at, so the files a package carries
222
+ // say where they came from rather than claiming version 0.
223
+ const state = await currentSchemaState(sources.db).catch(() => undefined)
224
+ const schema = await exportSchemaSet(sources.db, state?.version ?? '0')
225
+ return [
226
+ ...schemaEntries(schema, selection),
227
+ ...(await componentSection(sources, selection.components)),
228
+ ...(await fileEntries(sources.stylesheets, selection.stylesheets, 'styles')),
229
+ ...(await fileEntries(sources.scripts, selection.scripts, 'scripts')),
230
+ ...(selection.dictionaryItems.length > 0
231
+ ? await dictionaryEntries(sources.db, selection.dictionaryItems)
232
+ : []),
233
+ ]
234
+ }
package/src/tar.ts CHANGED
@@ -7,6 +7,12 @@
7
7
  * `Bun.gzipSync` does the compression. Timestamps are fixed, as npm fixes them,
8
8
  * so the same selection packs to the same bytes.
9
9
  */
10
+ import { once } from 'node:events'
11
+ import { createReadStream, createWriteStream, type WriteStream } from 'node:fs'
12
+ import { mkdir } from 'node:fs/promises'
13
+ import { dirname, join } from 'node:path'
14
+ import { pipeline } from 'node:stream/promises'
15
+ import { createGunzip, createGzip, type Gzip } from 'node:zlib'
10
16
 
11
17
  export interface TarEntry {
12
18
  /** The path inside the package, `/`-separated and never absolute. */
@@ -83,3 +89,161 @@ export function writeNpmTarball(entries: readonly TarEntry[]): Uint8Array<ArrayB
83
89
  }
84
90
  return Bun.gzipSync(tar) as Uint8Array<ArrayBuffer>
85
91
  }
92
+
93
+ const padding = (size: number) => (BLOCK - (size % BLOCK)) % BLOCK
94
+
95
+ const checkInside = (path: string) => {
96
+ if (path.startsWith('/') || path.split('/').includes('..'))
97
+ throw new Error(`${path} is not a path inside the package`)
98
+ }
99
+
100
+ /**
101
+ * An npm package tarball written as it goes (story 104), for a package too big
102
+ * to hold in memory: entries are gzipped straight to the file, and a file's
103
+ * bytes may arrive as a stream. The layout is `writeNpmTarball`'s.
104
+ */
105
+ export class TarballWriter {
106
+ #gzip: Gzip
107
+ #done: Promise<void>
108
+
109
+ private constructor(file: string) {
110
+ this.#gzip = createGzip()
111
+ const out = createWriteStream(file)
112
+ this.#done = pipeline(this.#gzip, out)
113
+ }
114
+
115
+ static create(file: string): TarballWriter {
116
+ return new TarballWriter(file)
117
+ }
118
+
119
+ async #write(bytes: Uint8Array): Promise<void> {
120
+ if (!this.#gzip.write(bytes)) await once(this.#gzip, 'drain')
121
+ }
122
+
123
+ async add(path: string, bytes: Uint8Array): Promise<void> {
124
+ checkInside(path)
125
+ await this.#write(header(`package/${path}`, bytes.length))
126
+ await this.#write(bytes)
127
+ const pad = padding(bytes.length)
128
+ if (pad > 0) await this.#write(new Uint8Array(pad))
129
+ }
130
+
131
+ /** A file whose `size` is known and whose bytes arrive in chunks; refused if they come to another size. */
132
+ async addStream(path: string, size: number, chunks: AsyncIterable<Uint8Array>): Promise<void> {
133
+ checkInside(path)
134
+ await this.#write(header(`package/${path}`, size))
135
+ let written = 0
136
+ for await (const chunk of chunks) {
137
+ written += chunk.length
138
+ if (written > size) throw new Error(`${path} is larger than its declared ${size} bytes`)
139
+ await this.#write(chunk)
140
+ }
141
+ if (written !== size) throw new Error(`${path} was ${written} bytes, not ${size}`)
142
+ const pad = padding(size)
143
+ if (pad > 0) await this.#write(new Uint8Array(pad))
144
+ }
145
+
146
+ async close(): Promise<void> {
147
+ await this.#write(new Uint8Array(BLOCK * 2))
148
+ this.#gzip.end()
149
+ await this.#done
150
+ }
151
+
152
+ /**
153
+ * Gives up on a half-written file: the stream ends now, and its error is
154
+ * swallowed, so nothing surfaces later after the caller has cleaned up.
155
+ */
156
+ async abort(): Promise<void> {
157
+ this.#gzip.destroy()
158
+ await this.#done.catch(() => {})
159
+ }
160
+ }
161
+
162
+ const decoder = new TextDecoder()
163
+
164
+ const field = (block: Uint8Array, offset: number, width: number) =>
165
+ decoder.decode(block.subarray(offset, offset + width)).replace(/\0.*$/s, '')
166
+
167
+ /**
168
+ * Unpacks a gzipped tarball into `dir`, streaming, and answers the paths it
169
+ * wrote. npm's `package/` folder is dropped, so `dir` holds what the package
170
+ * holds. Only regular files are written; a path that climbs out is refused.
171
+ */
172
+ export async function extractTarball(file: string, dir: string): Promise<string[]> {
173
+ const written: string[] = []
174
+ let pending = new Uint8Array(0)
175
+ let current: { out: WriteStream; left: number; skip: number } | undefined
176
+ let skipping = 0
177
+ let ended = false
178
+
179
+ const take = (n: number): Uint8Array | undefined => {
180
+ if (pending.length < n) return undefined
181
+ const head = pending.subarray(0, n)
182
+ pending = pending.subarray(n)
183
+ return head
184
+ }
185
+
186
+ const writeTo = async (out: WriteStream, bytes: Uint8Array) => {
187
+ if (!out.write(bytes)) await once(out, 'drain')
188
+ }
189
+
190
+ const finishEntry = async (out: WriteStream) => {
191
+ out.end()
192
+ await once(out, 'finish')
193
+ }
194
+
195
+ for await (const chunk of createReadStream(file).pipe(createGunzip())) {
196
+ const bytes = chunk as Uint8Array
197
+ const joined = new Uint8Array(pending.length + bytes.length)
198
+ joined.set(pending)
199
+ joined.set(bytes, pending.length)
200
+ pending = joined
201
+ while (!ended) {
202
+ if (current) {
203
+ if (current.left > 0) {
204
+ if (pending.length === 0) break
205
+ const part = pending.subarray(0, Math.min(current.left, pending.length))
206
+ pending = pending.subarray(part.length)
207
+ current.left -= part.length
208
+ await writeTo(current.out, part)
209
+ continue
210
+ }
211
+ skipping = current.skip
212
+ await finishEntry(current.out)
213
+ current = undefined
214
+ }
215
+ if (skipping > 0) {
216
+ const part = Math.min(skipping, pending.length)
217
+ pending = pending.subarray(part)
218
+ skipping -= part
219
+ if (skipping > 0) break
220
+ }
221
+ const block = take(BLOCK)
222
+ if (!block) break
223
+ if (block.every((byte) => byte === 0)) {
224
+ ended = true
225
+ break
226
+ }
227
+ const name = field(block, 0, 100)
228
+ const prefix = field(block, 345, 155)
229
+ const size = Number.parseInt(field(block, 124, 12).trim() || '0', 8)
230
+ const type = String.fromCharCode(block[156] ?? 48)
231
+ const full = prefix ? `${prefix}/${name}` : name
232
+ if (type !== '0' && type !== '\0') {
233
+ skipping = size + padding(size)
234
+ continue
235
+ }
236
+ const inside = full.replace(/^package\//, '')
237
+ checkInside(inside)
238
+ const target = join(dir, ...inside.split('/'))
239
+ await mkdir(dirname(target), { recursive: true })
240
+ written.push(inside)
241
+ current = { out: createWriteStream(target), left: size, skip: padding(size) }
242
+ }
243
+ }
244
+ if (current) {
245
+ if (current.left > 0) throw new Error(`${file} ends part-way through a file`)
246
+ await finishEntry(current.out)
247
+ }
248
+ return written
249
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Two-factor sign-in with an authenticator app (story 032), for backoffice users
3
+ * and members alike: setting it up, turning it on with ten recovery codes,
4
+ * checking a code at sign-in, and turning it off. The secret is sealed with
5
+ * `CROMBOLO_SECRET_KEY`, so two-factor sign-in cannot be turned on without the
6
+ * key. docs/operations.md.
7
+ *
8
+ * Setup is stateless, as Umbraco's is: the secret goes to the browser with its
9
+ * QR code, and comes back with the first code, which proves the app has it.
10
+ */
11
+ import {
12
+ generateRecoveryCodes,
13
+ generateTotpSecret,
14
+ hashRecoveryCode,
15
+ otpauthUri,
16
+ qrCodeDataUrl,
17
+ verifyTotp,
18
+ } from '@crombolo/auth'
19
+ import { type Db, TwoFactorRepository } from '@crombolo/data'
20
+ import { openSecret, SECRET_KEY_VARIABLE, sealSecret, usableSecretKey } from './secrets.ts'
21
+
22
+ /** Umbraco's names for the authenticator provider, which the backoffice matches by. */
23
+ export const USER_TWO_FACTOR_PROVIDER = 'UmbracoUserAppAuthenticator'
24
+ export const MEMBER_TWO_FACTOR_PROVIDER = 'UmbracoMemberAppAuthenticator'
25
+
26
+ export type TwoFactorResult<T = undefined> =
27
+ | ({ ok: true } & (T extends undefined ? object : { value: T }))
28
+ | { ok: false; reason: 'unavailable' | 'invalidCode' | 'notEnabled'; message: string }
29
+
30
+ export class TwoFactorService {
31
+ #repository: TwoFactorRepository
32
+ #key: string | undefined
33
+ #issuer: string
34
+
35
+ constructor(db: Db, options: { secretKey: string | undefined; issuer: string }) {
36
+ this.#repository = new TwoFactorRepository(db)
37
+ this.#key = usableSecretKey(options.secretKey)
38
+ this.#issuer = options.issuer
39
+ }
40
+
41
+ /** Whether two-factor sign-in can be turned on here: only with the key to seal secrets. */
42
+ get available(): boolean {
43
+ return this.#key !== undefined
44
+ }
45
+
46
+ #unavailable() {
47
+ return {
48
+ ok: false as const,
49
+ reason: 'unavailable' as const,
50
+ message: `Two-factor sign-in needs ${SECRET_KEY_VARIABLE} set on the server.`,
51
+ }
52
+ }
53
+
54
+ providers(ownerKey: string): Promise<string[]> {
55
+ return this.#repository.providers(ownerKey)
56
+ }
57
+
58
+ enabledAmong(ownerKeys: readonly string[]): Promise<Set<string>> {
59
+ return this.#repository.enabledAmong(ownerKeys)
60
+ }
61
+
62
+ /** A new secret, its QR code and the URI in it, for an account named `account`. */
63
+ setup(
64
+ account: string,
65
+ ): TwoFactorResult<{ secret: string; qrCodeSetupImageUrl: string; otpauthUri: string }> {
66
+ if (!this.#key) return this.#unavailable()
67
+ const secret = generateTotpSecret()
68
+ const uri = otpauthUri({ secret, account, issuer: this.#issuer })
69
+ return { ok: true, value: { secret, qrCodeSetupImageUrl: qrCodeDataUrl(uri), otpauthUri: uri } }
70
+ }
71
+
72
+ /** Turns the provider on when `code` is right for `secret`; answers with the recovery codes, shown once. */
73
+ async enable(
74
+ ownerKey: string,
75
+ provider: string,
76
+ secret: string,
77
+ code: string,
78
+ ): Promise<TwoFactorResult<{ recoveryCodes: string[] }>> {
79
+ if (!this.#key) return this.#unavailable()
80
+ const step = verifyTotp(secret, code)
81
+ if (step === undefined)
82
+ return {
83
+ ok: false,
84
+ reason: 'invalidCode',
85
+ message: 'That code is not right. Try the next one.',
86
+ }
87
+ const recoveryCodes = generateRecoveryCodes()
88
+ await this.#repository.enable(
89
+ ownerKey,
90
+ provider,
91
+ sealSecret(secret, this.#key),
92
+ step,
93
+ recoveryCodes.map(hashRecoveryCode),
94
+ )
95
+ return { ok: true, value: { recoveryCodes } }
96
+ }
97
+
98
+ /**
99
+ * Whether a code — from the app, or a recovery code — is this owner's, now. An
100
+ * accepted app code's step is recorded, so it cannot be used again; a recovery
101
+ * code is spent.
102
+ */
103
+ async verify(ownerKey: string, provider: string, code: string): Promise<boolean> {
104
+ const login = await this.#repository.get(ownerKey, provider)
105
+ if (!login || !this.#key) return false
106
+ const digits = code.replace(/\s/g, '')
107
+ if (/^\d{6}$/.test(digits)) {
108
+ let secret: string
109
+ try {
110
+ secret = openSecret(login.sealedSecret, this.#key)
111
+ } catch {
112
+ return false
113
+ }
114
+ const step = verifyTotp(secret, digits, { lastUsedStep: login.lastUsedStep })
115
+ return step !== undefined && (await this.#repository.useStep(ownerKey, provider, step))
116
+ }
117
+ return this.#repository.useRecoveryCode(ownerKey, hashRecoveryCode(code))
118
+ }
119
+
120
+ /** Turns a provider off for its owner, who proves it with a code. */
121
+ async disable(ownerKey: string, provider: string, code: string): Promise<TwoFactorResult> {
122
+ if (!(await this.#repository.get(ownerKey, provider)))
123
+ return { ok: false, reason: 'notEnabled', message: 'Two-factor sign-in is not on.' }
124
+ if (!(await this.verify(ownerKey, provider, code)))
125
+ return { ok: false, reason: 'invalidCode', message: 'That code is not right.' }
126
+ await this.#repository.disable(ownerKey, provider)
127
+ return { ok: true }
128
+ }
129
+
130
+ /** Turns it off for someone else, as an administrator does for a lost phone. */
131
+ async reset(ownerKey: string, provider?: string): Promise<void> {
132
+ await this.#repository.disable(ownerKey, provider)
133
+ }
134
+
135
+ recoveryCodesLeft(ownerKey: string): Promise<number> {
136
+ return this.#repository.recoveryCodesLeft(ownerKey)
137
+ }
138
+ }