@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
|
@@ -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
|
+
}
|