@crombolo/server 0.11.0 → 0.13.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 -11
- package/src/adapters/bundles.ts +2 -1
- package/src/adapters/content.ts +2 -0
- package/src/adapters/documents.ts +28 -25
- package/src/adapters/elements.ts +3 -0
- package/src/adapters/health.ts +333 -0
- package/src/adapters/media.ts +3 -0
- package/src/adapters/members.ts +10 -2
- package/src/adapters/references.ts +13 -0
- package/src/adapters/relations.ts +48 -0
- package/src/adapters/schema-files.ts +41 -11
- package/src/adapters/schema-proposals.ts +13 -6
- package/src/adapters/webhooks.ts +76 -0
- package/src/approvals-api.ts +189 -0
- package/src/approvals.ts +823 -0
- package/src/boot-failure.ts +147 -0
- package/src/bundle-install.ts +148 -13
- package/src/bundles.ts +14 -0
- package/src/busy.ts +13 -0
- package/src/config.ts +54 -2
- package/src/database.ts +9 -7
- package/src/delivery-members.ts +387 -0
- package/src/editor-types.ts +7 -2
- package/src/extensions-notice.ts +3 -2
- package/src/form-files.ts +38 -3
- package/src/form-submit.ts +62 -5
- package/src/form-types.ts +187 -0
- package/src/form-workflows.ts +66 -6
- package/src/forms.ts +15 -7
- package/src/index.ts +11 -0
- package/src/jobs.ts +17 -0
- package/src/marketplace.ts +18 -0
- package/src/media-files.ts +3 -1
- package/src/member-auth.ts +17 -1
- package/src/models-builder.ts +12 -8
- package/src/ports.ts +66 -2
- package/src/schema.ts +58 -11
- package/src/secrets.ts +36 -0
- package/src/server.ts +388 -57
- package/src/setup.ts +288 -0
- package/src/status.ts +5 -2
- package/src/transfer-dashboard.ts +219 -0
- package/src/upgrade-summary.ts +43 -0
- package/src/url-settings.ts +91 -0
- package/src/webhook-files.ts +138 -0
- package/src/webhooks.ts +544 -0
package/src/setup.ts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Set up your new site": the first administrator, created from the browser on a
|
|
3
|
+
* production install that was given no password. Guarded by `CROMBOLO_SETUP_CODE`,
|
|
4
|
+
* which the owner chooses and which nothing here ever prints, logs or echoes.
|
|
5
|
+
* docs/operations.md.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
8
|
+
import { hashPassword, passwordConfigJson } from '@crombolo/auth'
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_PASSWORD_CONFIGURATION,
|
|
11
|
+
isValidEmail,
|
|
12
|
+
isValidPassword,
|
|
13
|
+
type PasswordConfiguration,
|
|
14
|
+
} from '@crombolo/core'
|
|
15
|
+
import { completeSetup, type Db, readSetupState } from '@crombolo/data'
|
|
16
|
+
|
|
17
|
+
export const MIN_SETUP_CODE_LENGTH = 16
|
|
18
|
+
export const SETUP_CODE_VARIABLE = 'CROMBOLO_SETUP_CODE'
|
|
19
|
+
|
|
20
|
+
export type CodeCheck = 'unavailable' | 'accepted' | 'refused'
|
|
21
|
+
|
|
22
|
+
const digest = (value: string) => createHash('sha256').update(value).digest()
|
|
23
|
+
|
|
24
|
+
/** Compared as digests, so neither the length nor the content leaks through timing. */
|
|
25
|
+
export function checkSetupCode(configured: string | undefined, given: string): CodeCheck {
|
|
26
|
+
if (!configured || configured.length < MIN_SETUP_CODE_LENGTH) return 'unavailable'
|
|
27
|
+
return timingSafeEqual(digest(configured), digest(given)) ? 'accepted' : 'refused'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SetupForm {
|
|
31
|
+
name: string
|
|
32
|
+
email: string
|
|
33
|
+
password: string
|
|
34
|
+
confirm: string
|
|
35
|
+
code: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type SetupErrors = Partial<Record<keyof SetupForm, string>>
|
|
39
|
+
|
|
40
|
+
export function validateSetupForm(
|
|
41
|
+
form: SetupForm,
|
|
42
|
+
passwords: PasswordConfiguration = DEFAULT_PASSWORD_CONFIGURATION,
|
|
43
|
+
): SetupErrors {
|
|
44
|
+
const errors: SetupErrors = {}
|
|
45
|
+
if (!form.name.trim()) errors.name = 'Enter your name.'
|
|
46
|
+
if (!isValidEmail(form.email.trim())) errors.email = 'Enter an e-mail address.'
|
|
47
|
+
if (!isValidPassword(form.password, passwords))
|
|
48
|
+
errors.password = `Use at least ${passwords.minimumPasswordLength} characters.`
|
|
49
|
+
else if (form.password !== form.confirm) errors.confirm = 'The passwords do not match.'
|
|
50
|
+
if (!form.code) errors.code = 'Enter the setup code.'
|
|
51
|
+
return errors
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Failed attempts per caller within a window, and across all callers. */
|
|
55
|
+
export class AttemptLimiter {
|
|
56
|
+
#failures = new Map<string, number[]>()
|
|
57
|
+
#all: number[] = []
|
|
58
|
+
readonly #max: number
|
|
59
|
+
readonly #maxAll: number
|
|
60
|
+
readonly #windowMs: number
|
|
61
|
+
readonly #now: () => number
|
|
62
|
+
|
|
63
|
+
constructor(
|
|
64
|
+
options: { max?: number; maxAll?: number; windowMs?: number; now?: () => number } = {},
|
|
65
|
+
) {
|
|
66
|
+
this.#max = options.max ?? 5
|
|
67
|
+
this.#maxAll = options.maxAll ?? 50
|
|
68
|
+
this.#windowMs = options.windowMs ?? 15 * 60_000
|
|
69
|
+
this.#now = options.now ?? Date.now
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#recent(times: number[]): number[] {
|
|
73
|
+
const since = this.#now() - this.#windowMs
|
|
74
|
+
return times.filter((t) => t > since)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
blocked(key: string): boolean {
|
|
78
|
+
this.#all = this.#recent(this.#all)
|
|
79
|
+
const mine = this.#recent(this.#failures.get(key) ?? [])
|
|
80
|
+
this.#failures.set(key, mine)
|
|
81
|
+
return mine.length >= this.#max || this.#all.length >= this.#maxAll
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fail(key: string): void {
|
|
85
|
+
const now = this.#now()
|
|
86
|
+
this.#failures.set(key, [...(this.#failures.get(key) ?? []), now])
|
|
87
|
+
this.#all.push(now)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type SetupView =
|
|
92
|
+
| { kind: 'unavailable' }
|
|
93
|
+
| { kind: 'form'; errors: SetupErrors; name: string; email: string; limited?: boolean }
|
|
94
|
+
| { kind: 'done'; backOfficePath: string }
|
|
95
|
+
|
|
96
|
+
const escapeHtml = (value: string): string =>
|
|
97
|
+
value.replace(
|
|
98
|
+
/[&<>"']/g,
|
|
99
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] as string,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
function field(
|
|
103
|
+
label: string,
|
|
104
|
+
name: keyof SetupForm,
|
|
105
|
+
type: string,
|
|
106
|
+
errors: SetupErrors,
|
|
107
|
+
value = '',
|
|
108
|
+
autocomplete = 'off',
|
|
109
|
+
): string {
|
|
110
|
+
const error = errors[name]
|
|
111
|
+
return `<label for="${name}">${label}</label>
|
|
112
|
+
<input id="${name}" name="${name}" type="${type}" value="${escapeHtml(value)}" autocomplete="${autocomplete}" required${error ? ` aria-invalid="true" aria-describedby="${name}-error"` : ''}>
|
|
113
|
+
${error ? `<p class="error" id="${name}-error">${escapeHtml(error)}</p>` : ''}`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The page itself: plain HTML, no script, so it works before anything else does. */
|
|
117
|
+
export function setupPage(view: SetupView, siteName: string): string {
|
|
118
|
+
const site = escapeHtml(siteName)
|
|
119
|
+
let body: string
|
|
120
|
+
if (view.kind === 'unavailable') {
|
|
121
|
+
body = `<h1>Set up your new site</h1>
|
|
122
|
+
<p>${site} is running, and is ready for its first administrator.</p>
|
|
123
|
+
<p>To protect this page, add a setting named <code>${SETUP_CODE_VARIABLE}</code> to the server, with a value of at least ${MIN_SETUP_CODE_LENGTH} characters that only you know. Restart the server, then come back to this page and enter it.</p>
|
|
124
|
+
<p class="fine">Where you add settings depends on where the site runs: in your hosting provider's console it is usually called environment variables.</p>`
|
|
125
|
+
} else if (view.kind === 'done') {
|
|
126
|
+
body = `<h1>Your site is ready</h1>
|
|
127
|
+
<p>Your administrator account has been created.</p>
|
|
128
|
+
<p><a class="cta" href="${escapeHtml(view.backOfficePath)}">Sign in</a></p>
|
|
129
|
+
<p class="fine">You can now remove the <code>${SETUP_CODE_VARIABLE}</code> setting from the server: it is not needed again.</p>`
|
|
130
|
+
} else {
|
|
131
|
+
const { errors } = view
|
|
132
|
+
body = `<h1>Set up your new site</h1>
|
|
133
|
+
<p>Create the administrator account for ${site}.</p>
|
|
134
|
+
${view.limited ? '<p class="error" role="alert">Too many attempts. Wait a few minutes and try again.</p>' : ''}
|
|
135
|
+
<form method="post">
|
|
136
|
+
${field('Your name', 'name', 'text', errors, view.name, 'name')}
|
|
137
|
+
${field('E-mail', 'email', 'email', errors, view.email, 'email')}
|
|
138
|
+
${field('Password', 'password', 'password', errors, '', 'new-password')}
|
|
139
|
+
${field('Confirm password', 'confirm', 'password', errors, '', 'new-password')}
|
|
140
|
+
${field('Setup code', 'code', 'password', errors)}
|
|
141
|
+
<p class="fine">The setup code is the value of the <code>${SETUP_CODE_VARIABLE}</code> setting on the server.</p>
|
|
142
|
+
<button type="submit">Create the account</button>
|
|
143
|
+
</form>`
|
|
144
|
+
}
|
|
145
|
+
return `<!doctype html>
|
|
146
|
+
<html lang="en">
|
|
147
|
+
<head>
|
|
148
|
+
<meta charset="utf-8">
|
|
149
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
150
|
+
<meta name="robots" content="noindex">
|
|
151
|
+
<title>Set up your new site</title>
|
|
152
|
+
<style>
|
|
153
|
+
:root { color-scheme: light dark; }
|
|
154
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center;
|
|
155
|
+
font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
156
|
+
background: #f6f5f3; color: #1b1b1b; }
|
|
157
|
+
main { width: 100%; max-width: 30rem; padding: 2.5rem 1rem; box-sizing: border-box; }
|
|
158
|
+
h1 { font-size: 1.6rem; margin: 0 0 .75rem; }
|
|
159
|
+
p { margin: 0 0 1rem; }
|
|
160
|
+
label { display: block; font-weight: 600; margin: 1rem 0 .25rem; }
|
|
161
|
+
input { width: 100%; box-sizing: border-box; padding: .5rem; font: inherit; border: 1px solid #b9b6ae; border-radius: 4px; }
|
|
162
|
+
button, a.cta { display: inline-block; margin-top: 1.25rem; padding: .55rem 1rem; border: 0; border-radius: 4px;
|
|
163
|
+
background: #1b1b1b; color: #fff; font: inherit; text-decoration: none; cursor: pointer; }
|
|
164
|
+
code { font-size: .9em; background: #e9e7e2; padding: .1em .35em; border-radius: 3px; }
|
|
165
|
+
.error { color: #a3261b; margin: .25rem 0 0; }
|
|
166
|
+
.fine { font-size: .85rem; color: #6b6b63; }
|
|
167
|
+
@media (prefers-color-scheme: dark) {
|
|
168
|
+
body { background: #16171a; color: #e8e6e3; }
|
|
169
|
+
input { background: #1f2124; color: inherit; border-color: #45474b; }
|
|
170
|
+
code { background: #26282c; }
|
|
171
|
+
button, a.cta { background: #e8e6e3; color: #16171a; }
|
|
172
|
+
.error { color: #f08a7e; }
|
|
173
|
+
.fine { color: #9a9a93; }
|
|
174
|
+
}
|
|
175
|
+
</style>
|
|
176
|
+
</head>
|
|
177
|
+
<body>
|
|
178
|
+
<main>
|
|
179
|
+
${body}
|
|
180
|
+
</main>
|
|
181
|
+
</body>
|
|
182
|
+
</html>
|
|
183
|
+
`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface SetupRoutesOptions {
|
|
187
|
+
db: Db
|
|
188
|
+
setupCode: string | undefined
|
|
189
|
+
siteName: string
|
|
190
|
+
backOfficePath: string
|
|
191
|
+
/** Who is asking, for the attempt limit. */
|
|
192
|
+
clientKey: (request: Request) => string
|
|
193
|
+
limiter?: AttemptLimiter
|
|
194
|
+
passwords?: PasswordConfiguration
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface SetupRoutes {
|
|
198
|
+
readonly path: string
|
|
199
|
+
/** True until the first administrator exists; read from the database until then. */
|
|
200
|
+
pending(): Promise<boolean>
|
|
201
|
+
handle(request: Request): Promise<Response>
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const page = (html: string, status = 200) =>
|
|
205
|
+
new Response(html, {
|
|
206
|
+
status,
|
|
207
|
+
headers: {
|
|
208
|
+
'content-type': 'text/html; charset=utf-8',
|
|
209
|
+
'cache-control': 'no-store',
|
|
210
|
+
'x-frame-options': 'DENY',
|
|
211
|
+
'referrer-policy': 'no-referrer',
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
export function createSetupRoutes(options: SetupRoutesOptions): SetupRoutes {
|
|
216
|
+
const path = `${options.backOfficePath}/setup`
|
|
217
|
+
const limiter = options.limiter ?? new AttemptLimiter()
|
|
218
|
+
let closed = false
|
|
219
|
+
|
|
220
|
+
const pending = async () => {
|
|
221
|
+
if (closed) return false
|
|
222
|
+
const state = await readSetupState(options.db)
|
|
223
|
+
if (state !== 'pending') closed = true
|
|
224
|
+
return state === 'pending'
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const render = (view: SetupView, status = 200) => page(setupPage(view, options.siteName), status)
|
|
228
|
+
const formView = (
|
|
229
|
+
errors: SetupErrors,
|
|
230
|
+
form?: Partial<SetupForm>,
|
|
231
|
+
limited = false,
|
|
232
|
+
): SetupView => ({
|
|
233
|
+
kind: 'form',
|
|
234
|
+
errors,
|
|
235
|
+
name: form?.name ?? '',
|
|
236
|
+
email: form?.email ?? '',
|
|
237
|
+
limited,
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
path,
|
|
242
|
+
pending,
|
|
243
|
+
async handle(request) {
|
|
244
|
+
if (!(await pending()))
|
|
245
|
+
return new Response(null, { status: 303, headers: { location: options.backOfficePath } })
|
|
246
|
+
const configured = checkSetupCode(options.setupCode, '')
|
|
247
|
+
if (configured === 'unavailable') return render({ kind: 'unavailable' })
|
|
248
|
+
if (request.method === 'GET') return render(formView({}))
|
|
249
|
+
if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405 })
|
|
250
|
+
|
|
251
|
+
const origin = request.headers.get('origin')
|
|
252
|
+
if (origin && origin !== new URL(request.url).origin)
|
|
253
|
+
return new Response('Forbidden', { status: 403 })
|
|
254
|
+
|
|
255
|
+
const key = options.clientKey(request)
|
|
256
|
+
const data = await request.formData().catch(() => new FormData())
|
|
257
|
+
const value = (name: string) => {
|
|
258
|
+
const entry = data.get(name)
|
|
259
|
+
return typeof entry === 'string' ? entry : ''
|
|
260
|
+
}
|
|
261
|
+
const form: SetupForm = {
|
|
262
|
+
name: value('name'),
|
|
263
|
+
email: value('email'),
|
|
264
|
+
password: value('password'),
|
|
265
|
+
confirm: value('confirm'),
|
|
266
|
+
code: value('code'),
|
|
267
|
+
}
|
|
268
|
+
if (limiter.blocked(key)) return render(formView({}, form, true), 429)
|
|
269
|
+
const errors = validateSetupForm(form, options.passwords)
|
|
270
|
+
if (Object.keys(errors).length > 0) return render(formView(errors, form), 400)
|
|
271
|
+
if (checkSetupCode(options.setupCode, form.code) !== 'accepted') {
|
|
272
|
+
limiter.fail(key)
|
|
273
|
+
return render(formView({ code: 'That is not the setup code.' }, form), 400)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const email = form.email.trim()
|
|
277
|
+
const result = await completeSetup(
|
|
278
|
+
options.db,
|
|
279
|
+
{ name: form.name.trim(), login: email, email, password: form.password },
|
|
280
|
+
{ hashPassword, passwordConfig: passwordConfigJson() },
|
|
281
|
+
)
|
|
282
|
+
closed = true
|
|
283
|
+
if (result === 'closed')
|
|
284
|
+
return new Response(null, { status: 303, headers: { location: options.backOfficePath } })
|
|
285
|
+
return render({ kind: 'done', backOfficePath: options.backOfficePath })
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
}
|
package/src/status.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { loadSchemaDirectory } from '@crombolo/schema'
|
|
|
30
30
|
import { BundleConfigurationError, bundleChainsFor, pendingBundleSteps } from './bundle-tables.ts'
|
|
31
31
|
import { type CromboloConfig, describeDatabase, VERSION } from './config.ts'
|
|
32
32
|
import { type EmailAvailability, emailAvailability, resolveEmailPort } from './email.ts'
|
|
33
|
+
import { formTypesFor } from './form-types.ts'
|
|
33
34
|
import { assertViewRuntime } from './view-runtime.ts'
|
|
34
35
|
|
|
35
36
|
export interface SiteStatus {
|
|
@@ -111,7 +112,7 @@ async function contentCounts(db: Db): Promise<SiteStatus['content']> {
|
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
/** The vendored client's version, which is a fact about this deployment too. */
|
|
114
|
-
function backofficeVersion(): string | undefined {
|
|
115
|
+
export function backofficeVersion(): string | undefined {
|
|
115
116
|
for (const base of [join(import.meta.dir, '..', '..'), process.cwd()]) {
|
|
116
117
|
const file = join(base, 'backoffice-dist', 'package.json')
|
|
117
118
|
if (!existsSync(file)) continue
|
|
@@ -125,7 +126,9 @@ function backofficeVersion(): string | undefined {
|
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
export async function siteStatus(config: CromboloConfig): Promise<SiteStatus> {
|
|
128
|
-
const loaded = existsSync(config.schemaDir)
|
|
129
|
+
const loaded = existsSync(config.schemaDir)
|
|
130
|
+
? loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
|
|
131
|
+
: undefined
|
|
129
132
|
const status: SiteStatus = {
|
|
130
133
|
site: {
|
|
131
134
|
name: config.siteName,
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answering a content import's conflicts, and running it, from the Changes
|
|
3
|
+
* dashboard. docs/content-transfer.md, "In the backoffice".
|
|
4
|
+
*
|
|
5
|
+
* The answers are stored, not written beside the bundle: an installed bundle
|
|
6
|
+
* lives in `node_modules`, which a redeploy replaces and other nodes do not
|
|
7
|
+
* share. They are laid over the bundle's own `resolutions.json` at import, and
|
|
8
|
+
* can be downloaded as that file to commit with it.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
type Db,
|
|
12
|
+
type PendingTransfer,
|
|
13
|
+
type TransferAnswer,
|
|
14
|
+
TransferAnswerRepository,
|
|
15
|
+
} from '@crombolo/data'
|
|
16
|
+
import { isResolution, nodeResolutionProblems, type Resolution } from '@crombolo/transfer'
|
|
17
|
+
import { BackupRequiredError } from './backup.ts'
|
|
18
|
+
import {
|
|
19
|
+
CommandError,
|
|
20
|
+
checkBundleContent,
|
|
21
|
+
installBundleContent,
|
|
22
|
+
PassphraseError,
|
|
23
|
+
pendingResolutionsFile,
|
|
24
|
+
} from './bundle-install.ts'
|
|
25
|
+
import type { CromboloConfig } from './config.ts'
|
|
26
|
+
|
|
27
|
+
export interface PendingTransferView {
|
|
28
|
+
bundleId: string
|
|
29
|
+
label: string | null
|
|
30
|
+
checkedAt: string
|
|
31
|
+
publish: boolean
|
|
32
|
+
allowMissingBlobs: boolean
|
|
33
|
+
answers: Array<Pick<TransferAnswer, 'nodeKey' | 'handle' | 'resolution' | 'answeredBy'>>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The imports waiting on the dashboard, with the answers given so far. */
|
|
37
|
+
export async function pendingTransfers(db: Db): Promise<PendingTransferView[]> {
|
|
38
|
+
const repo = new TransferAnswerRepository(db)
|
|
39
|
+
const out: PendingTransferView[] = []
|
|
40
|
+
for (const pending of await repo.allPending())
|
|
41
|
+
out.push({
|
|
42
|
+
bundleId: pending.bundleId,
|
|
43
|
+
label: pending.label,
|
|
44
|
+
checkedAt: pending.checkedAt.toISOString(),
|
|
45
|
+
publish: pending.publish,
|
|
46
|
+
allowMissingBlobs: pending.allowMissingBlobs,
|
|
47
|
+
answers: (await repo.answers(pending.bundleId)).map(
|
|
48
|
+
({ nodeKey, handle, resolution, answeredBy }) => ({
|
|
49
|
+
nodeKey,
|
|
50
|
+
handle,
|
|
51
|
+
resolution,
|
|
52
|
+
answeredBy,
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
})
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const HANDLE = /^(\*|[A-Za-z][A-Za-z0-9_]*(@[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*)?)$/
|
|
60
|
+
const NODE_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
61
|
+
|
|
62
|
+
/** What is wrong with an answer as sent, if anything. Pure. */
|
|
63
|
+
export function answerProblems(body: Record<string, unknown>): string[] {
|
|
64
|
+
const nodeKey = typeof body.nodeKey === 'string' ? body.nodeKey : ''
|
|
65
|
+
const handle = typeof body.handle === 'string' ? body.handle : ''
|
|
66
|
+
if (!NODE_KEY.test(nodeKey)) return ['The answer names no item.']
|
|
67
|
+
if (!HANDLE.test(handle)) return ['The answer names no field.']
|
|
68
|
+
if (body.resolution === null) return []
|
|
69
|
+
if (typeof body.resolution !== 'string' || !isResolution(body.resolution))
|
|
70
|
+
return ['That is not one of the answers on offer.']
|
|
71
|
+
return nodeResolutionProblems(nodeKey, { [handle]: body.resolution }).length > 0
|
|
72
|
+
? ['Only a whole item can be left out.']
|
|
73
|
+
: []
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface TransferActionContext {
|
|
77
|
+
db: Db
|
|
78
|
+
config: CromboloConfig
|
|
79
|
+
/** Who is acting, recorded with each answer. */
|
|
80
|
+
user: string
|
|
81
|
+
/** After an import lands: the published cache was built before it. */
|
|
82
|
+
onApplied(): void
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const unreadable = (error: CommandError) =>
|
|
86
|
+
Response.json(
|
|
87
|
+
{
|
|
88
|
+
ok: false,
|
|
89
|
+
message: 'This bundle can no longer be read on this server.',
|
|
90
|
+
detail: error.problems,
|
|
91
|
+
},
|
|
92
|
+
{ status: 409 },
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async function recheck(
|
|
96
|
+
ctx: TransferActionContext,
|
|
97
|
+
pending: PendingTransfer,
|
|
98
|
+
passphrase: string | undefined,
|
|
99
|
+
) {
|
|
100
|
+
const result = await checkBundleContent(ctx.db, ctx.config, {
|
|
101
|
+
bundle: pending.target,
|
|
102
|
+
under: pending.under ?? undefined,
|
|
103
|
+
publish: pending.publish,
|
|
104
|
+
allowMissingBlobs: pending.allowMissingBlobs,
|
|
105
|
+
by: ctx.user,
|
|
106
|
+
passphrase,
|
|
107
|
+
})
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
outstanding: result.check.outstanding.length,
|
|
111
|
+
blocking: result.check.outstanding.filter((f) => f.kind === 'blocking').length,
|
|
112
|
+
publishRefusals: result.publishRefusals.length,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `<bundleId>/answer`, `/missing-files`, `/check`, `/import` and
|
|
118
|
+
* `/resolutions`. Undefined for anything else.
|
|
119
|
+
*/
|
|
120
|
+
export async function handleTransferAction(
|
|
121
|
+
request: Request,
|
|
122
|
+
action: string,
|
|
123
|
+
ctx: TransferActionContext,
|
|
124
|
+
): Promise<Response | undefined> {
|
|
125
|
+
const match = /^([^/]+)\/(answer|missing-files|check|import|resolutions)$/.exec(action)
|
|
126
|
+
if (!match) return undefined
|
|
127
|
+
const bundleId = decodeURIComponent(match[1] as string)
|
|
128
|
+
const verb = match[2] as string
|
|
129
|
+
const repo = new TransferAnswerRepository(ctx.db)
|
|
130
|
+
const pending = await repo.pending(bundleId)
|
|
131
|
+
if (!pending)
|
|
132
|
+
return Response.json(
|
|
133
|
+
{ ok: false, message: 'Nothing is waiting to be imported from this bundle.' },
|
|
134
|
+
{ status: 404 },
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
if (verb === 'resolutions' && request.method === 'GET') {
|
|
139
|
+
const file = (await pendingResolutionsFile(ctx.db, ctx.config, bundleId)) as string
|
|
140
|
+
return new Response(file, {
|
|
141
|
+
headers: {
|
|
142
|
+
'content-type': 'application/json',
|
|
143
|
+
'content-disposition': 'attachment; filename="resolutions.json"',
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
if (request.method !== 'POST') return undefined
|
|
148
|
+
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>
|
|
149
|
+
// For this request only: an encrypted bundle's passphrase is never stored.
|
|
150
|
+
const passphrase =
|
|
151
|
+
typeof body.passphrase === 'string' && body.passphrase !== '' ? body.passphrase : undefined
|
|
152
|
+
|
|
153
|
+
if (verb === 'answer') {
|
|
154
|
+
const problems = answerProblems(body)
|
|
155
|
+
if (problems.length > 0)
|
|
156
|
+
return Response.json({ ok: false, message: problems[0] }, { status: 400 })
|
|
157
|
+
await repo.answer(bundleId, {
|
|
158
|
+
nodeKey: body.nodeKey as string,
|
|
159
|
+
handle: body.handle as string,
|
|
160
|
+
resolution: (body.resolution as Resolution | null) ?? null,
|
|
161
|
+
by: ctx.user,
|
|
162
|
+
})
|
|
163
|
+
return Response.json(await recheck(ctx, pending, passphrase))
|
|
164
|
+
}
|
|
165
|
+
if (verb === 'missing-files') {
|
|
166
|
+
await repo.setAllowMissingBlobs(bundleId, body.allow === true)
|
|
167
|
+
return Response.json(
|
|
168
|
+
await recheck(ctx, { ...pending, allowMissingBlobs: body.allow === true }, passphrase),
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
if (verb === 'check') return Response.json(await recheck(ctx, pending, passphrase))
|
|
172
|
+
|
|
173
|
+
const result = await installBundleContent(ctx.db, ctx.config, {
|
|
174
|
+
bundle: pending.target,
|
|
175
|
+
under: pending.under ?? undefined,
|
|
176
|
+
publish: pending.publish,
|
|
177
|
+
allowMissingBlobs: pending.allowMissingBlobs,
|
|
178
|
+
backupTaken: body.backupTaken === true,
|
|
179
|
+
label: pending.label ?? undefined,
|
|
180
|
+
by: ctx.user,
|
|
181
|
+
passphrase,
|
|
182
|
+
})
|
|
183
|
+
if (result.runId) ctx.onApplied()
|
|
184
|
+
const names = new Map(result.check.plan.map((entry) => [entry.key, entry.name] as const))
|
|
185
|
+
return Response.json(
|
|
186
|
+
{
|
|
187
|
+
ok: Boolean(result.runId),
|
|
188
|
+
runId: result.runId ?? null,
|
|
189
|
+
counts: result.check.counts,
|
|
190
|
+
published: result.published,
|
|
191
|
+
publishFailures: result.publishFailures.map((failure) => ({
|
|
192
|
+
name: names.get(failure.key) ?? failure.key,
|
|
193
|
+
reason: failure.reason,
|
|
194
|
+
})),
|
|
195
|
+
outstanding: result.check.outstanding.length,
|
|
196
|
+
problems: result.problems,
|
|
197
|
+
},
|
|
198
|
+
{ status: result.runId ? 200 : 409 },
|
|
199
|
+
)
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (error instanceof PassphraseError)
|
|
202
|
+
return Response.json(
|
|
203
|
+
{ ok: false, needsPassphrase: true, message: error.message },
|
|
204
|
+
{ status: 409 },
|
|
205
|
+
)
|
|
206
|
+
if (error instanceof CommandError) return unreadable(error)
|
|
207
|
+
if (error instanceof BackupRequiredError)
|
|
208
|
+
return Response.json(
|
|
209
|
+
{
|
|
210
|
+
ok: false,
|
|
211
|
+
needsBackup: true,
|
|
212
|
+
message:
|
|
213
|
+
'This site takes no backup by itself before an import. Take one, then confirm it to go ahead.',
|
|
214
|
+
},
|
|
215
|
+
{ status: 409 },
|
|
216
|
+
)
|
|
217
|
+
throw error
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** What the Changes dashboard says about this site's installs and upgrades, read from the ledger. */
|
|
2
|
+
import type { LedgerRow } from '@crombolo/data'
|
|
3
|
+
|
|
4
|
+
export interface UpgradeSummary {
|
|
5
|
+
version: string
|
|
6
|
+
installedAt: string | null
|
|
7
|
+
/** The last run of framework steps after the install, if there has been one. */
|
|
8
|
+
lastUpgrade: { at: string; release: string | null; steps: number } | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Steps this far apart or closer belong to one run. */
|
|
12
|
+
const RUN_GAP_MS = 10 * 60_000
|
|
13
|
+
|
|
14
|
+
/** SQLite and Postgres hand dates back differently; the dashboard gets ISO. */
|
|
15
|
+
const iso = (at: string) => {
|
|
16
|
+
const time = Date.parse(at)
|
|
17
|
+
return Number.isNaN(time) ? at : new Date(time).toISOString()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const releaseOf = (row: LedgerRow) => /^release (\S+)/.exec(row.note ?? '')?.[1] ?? null
|
|
21
|
+
|
|
22
|
+
export function summariseUpgrades(ledger: readonly LedgerRow[], version: string): UpgradeSummary {
|
|
23
|
+
const core = ledger
|
|
24
|
+
.filter((row) => !row.bundle && (row.kind === 'expand' || row.kind === 'contract'))
|
|
25
|
+
.sort((a, b) => a.id - b.id)
|
|
26
|
+
const first = core[0]
|
|
27
|
+
if (!first) return { version, installedAt: null, lastUpgrade: null }
|
|
28
|
+
let start = core.length - 1
|
|
29
|
+
while (
|
|
30
|
+
start > 0 &&
|
|
31
|
+
Date.parse(core[start]?.appliedAt ?? '') - Date.parse(core[start - 1]?.appliedAt ?? '') <=
|
|
32
|
+
RUN_GAP_MS
|
|
33
|
+
)
|
|
34
|
+
start--
|
|
35
|
+
const run = core.slice(start)
|
|
36
|
+
const last = run.at(-1) as LedgerRow
|
|
37
|
+
return {
|
|
38
|
+
version,
|
|
39
|
+
installedAt: iso(first.appliedAt),
|
|
40
|
+
lastUpgrade:
|
|
41
|
+
start === 0 ? null : { at: iso(last.appliedAt), release: releaseOf(last), steps: run.length },
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the site's URLs are built (story 110): Umbraco's
|
|
3
|
+
* `HideTopLevelNodeFromPath`, kept in `schema/schema.toml` and changed there or
|
|
4
|
+
* in Settings. Either way every URL can move, so the old ones are redirected,
|
|
5
|
+
* as a rename's are. docs/rendering.md, "URLs".
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from 'node:fs'
|
|
9
|
+
import {
|
|
10
|
+
appendCacheInstruction,
|
|
11
|
+
type Db,
|
|
12
|
+
readUrlOptions,
|
|
13
|
+
sameUrlOptions,
|
|
14
|
+
type UrlOptions,
|
|
15
|
+
writeUrlOptions,
|
|
16
|
+
} from '@crombolo/data'
|
|
17
|
+
import {
|
|
18
|
+
type DocumentRoute,
|
|
19
|
+
PublishedCache,
|
|
20
|
+
type PublishedCacheOptions,
|
|
21
|
+
type PublishedContentSource,
|
|
22
|
+
} from '@crombolo/render'
|
|
23
|
+
import { createSchemaFileWriter } from './adapters/schema-files.ts'
|
|
24
|
+
import type { CromboloConfig } from './config.ts'
|
|
25
|
+
import { schemaFiles } from './ports.ts'
|
|
26
|
+
import type { RedirectTracker } from './redirects.ts'
|
|
27
|
+
|
|
28
|
+
export interface UrlChangeContext {
|
|
29
|
+
db: Db
|
|
30
|
+
config: CromboloConfig
|
|
31
|
+
source: PublishedContentSource
|
|
32
|
+
cacheOptions?: PublishedCacheOptions
|
|
33
|
+
cache: PublishedCache
|
|
34
|
+
tracker: RedirectTracker
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Every route the site had under `before`, read from a cache built with it,
|
|
39
|
+
* then handed to the tracker against the routes it has now. The live cache is
|
|
40
|
+
* dropped first, so "now" is the new setting.
|
|
41
|
+
*/
|
|
42
|
+
export async function redirectAfterUrlChange(
|
|
43
|
+
ctx: UrlChangeContext,
|
|
44
|
+
before: UrlOptions,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const old = new PublishedCache(
|
|
47
|
+
{ ...ctx.source, loadUrlOptions: async () => before },
|
|
48
|
+
ctx.cacheOptions,
|
|
49
|
+
)
|
|
50
|
+
const captured: DocumentRoute[] = []
|
|
51
|
+
for (const key of await old.childKeys(null)) captured.push(...(await old.routes(key, true)))
|
|
52
|
+
ctx.cache.invalidate()
|
|
53
|
+
await ctx.tracker.commit(captured)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type UrlSettingOutcome =
|
|
57
|
+
| { ok: true; options: UrlOptions }
|
|
58
|
+
| { ok: false; status: number; message: string }
|
|
59
|
+
|
|
60
|
+
/** Whether Settings may change it here: not when the files are kept read-only. Pure. */
|
|
61
|
+
export function urlSettingEditable(
|
|
62
|
+
config: Pick<CromboloConfig, 'schemaDir' | 'schemaWritable'>,
|
|
63
|
+
managed: boolean,
|
|
64
|
+
): boolean {
|
|
65
|
+
return !managed || config.schemaWritable
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The change Settings makes: the database, the file, redirects, and the other nodes. */
|
|
69
|
+
export async function changeUrlOptions(
|
|
70
|
+
ctx: UrlChangeContext,
|
|
71
|
+
next: UrlOptions,
|
|
72
|
+
): Promise<UrlSettingOutcome> {
|
|
73
|
+
if (!urlSettingEditable(ctx.config, existsSync(ctx.config.schemaDir)))
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
status: 409,
|
|
77
|
+
message:
|
|
78
|
+
'This site keeps its address settings with its files, and they cannot be changed in this environment.',
|
|
79
|
+
}
|
|
80
|
+
const before = await readUrlOptions(ctx.db)
|
|
81
|
+
if (sameUrlOptions(before, next)) return { ok: true, options: before }
|
|
82
|
+
await writeUrlOptions(ctx.db, next)
|
|
83
|
+
await createSchemaFileWriter(ctx.db, schemaFiles(ctx.config)).writeUrlOptions(next)
|
|
84
|
+
await redirectAfterUrlChange(ctx, before)
|
|
85
|
+
await appendCacheInstruction(ctx.db, {
|
|
86
|
+
kind: 'schema',
|
|
87
|
+
payload: { urls: next },
|
|
88
|
+
by: ctx.config.nodeId,
|
|
89
|
+
})
|
|
90
|
+
return { ok: true, options: next }
|
|
91
|
+
}
|