@crombolo/server 0.14.1 → 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/api-client.ts +47 -0
- package/src/bundle-install.ts +18 -1
- package/src/config.ts +109 -2
- package/src/database.ts +21 -8
- package/src/index.ts +4 -0
- package/src/media-store-azure.ts +1 -1
- package/src/media-store.ts +4 -2
- package/src/remote-credentials.ts +108 -0
- package/src/remote-media.ts +91 -0
- package/src/remote.ts +793 -0
- package/src/server.ts +83 -4
- package/src/site-file.ts +45 -11
- package/src/tar.ts +9 -0
package/src/remote.ts
ADDED
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The remote protocol, version 1 (story 107): this server answering a
|
|
3
|
+
* developer's `crombolo` — discovery, its one environment, pulls and pushes of
|
|
4
|
+
* whole-site files as jobs, media by reference, and approval of protected
|
|
5
|
+
* pushes. The contract is `docs/remote-protocol.md`; this is the reference
|
|
6
|
+
* implementation of it.
|
|
7
|
+
*
|
|
8
|
+
* Off unless `remote.enabled`. A job runs in-process on the node that took the
|
|
9
|
+
* request, its state in `remote_job` so any node can answer a poll, its file in
|
|
10
|
+
* the media store so any node can serve the download. A node that stops leaves
|
|
11
|
+
* its job's heartbeat to go stale, and the job is then reported failed.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
import { createWriteStream } from 'node:fs'
|
|
15
|
+
import { mkdir, rm } from 'node:fs/promises'
|
|
16
|
+
import { tmpdir } from 'node:os'
|
|
17
|
+
import { join, sep } from 'node:path'
|
|
18
|
+
import type { Principal } from '@crombolo/api-management'
|
|
19
|
+
import { DEVICE_CODE_GRANT, REMOTE_CLIENT_ID } from '@crombolo/auth'
|
|
20
|
+
import { MANAGEMENT_API_PATH, RemotePermissions, type RemoteVerb } from '@crombolo/core'
|
|
21
|
+
import { type Db, type RemoteJob, RemoteJobRepository } from '@crombolo/data'
|
|
22
|
+
import type { ContentSet } from '@crombolo/transfer'
|
|
23
|
+
import { recordAudit } from './adapters/audit.ts'
|
|
24
|
+
import { installBundleContent } from './bundle-install.ts'
|
|
25
|
+
import type { CromboloConfig } from './config.ts'
|
|
26
|
+
import { mediaStoreFor } from './media-files.ts'
|
|
27
|
+
import { type MediaStore, streamOf } from './media-store.ts'
|
|
28
|
+
import { openSecret, sealSecret, usableSecretKey } from './secrets.ts'
|
|
29
|
+
import { exportSite } from './site-file.ts'
|
|
30
|
+
import { replaceSummary } from './site-replace.ts'
|
|
31
|
+
import { extractTarball } from './tar.ts'
|
|
32
|
+
|
|
33
|
+
export const REMOTE_PROTOCOL_VERSION = '1'
|
|
34
|
+
export const REMOTE_DISCOVERY_PATH = '/.well-known/crombolo-remote'
|
|
35
|
+
export const REMOTE_API_PATH = '/umbraco/remote/v1'
|
|
36
|
+
|
|
37
|
+
/** How long a finished job's file stays downloadable, and its row with it. */
|
|
38
|
+
const JOB_FILE_SECONDS = 60 * 60
|
|
39
|
+
/** A running job not heard from in this long is reported failed. */
|
|
40
|
+
const STALE_SECONDS = 10 * 60
|
|
41
|
+
/** How long a protected push may wait for its approval. */
|
|
42
|
+
const APPROVAL_SECONDS = 24 * 60 * 60
|
|
43
|
+
const MIN_PASSPHRASE = 12
|
|
44
|
+
const MAX_LINKS = 500
|
|
45
|
+
const POLL_SECONDS = 2
|
|
46
|
+
|
|
47
|
+
export interface RemoteOptions {
|
|
48
|
+
db: Db
|
|
49
|
+
config: CromboloConfig
|
|
50
|
+
authenticate(request: Request): Promise<Principal | undefined>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Remote {
|
|
54
|
+
/** Answers a remote path, or undefined when the path is not one. */
|
|
55
|
+
handle(request: Request, url: URL): Promise<Response | undefined>
|
|
56
|
+
/** Whether a request is an upload the remote takes beyond the usual body limit. */
|
|
57
|
+
isUpload(request: Request, url: URL): boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Problem {
|
|
61
|
+
code: string
|
|
62
|
+
title: string
|
|
63
|
+
detail?: string
|
|
64
|
+
items?: string[]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const problem = (status: number, body: Problem) =>
|
|
68
|
+
new Response(JSON.stringify({ status, ...body }), {
|
|
69
|
+
status,
|
|
70
|
+
headers: { 'content-type': 'application/problem+json' },
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const may = (principal: Principal, verb: RemoteVerb) =>
|
|
74
|
+
principal.isAdmin || principal.permissions.includes(verb)
|
|
75
|
+
|
|
76
|
+
/** A cookie-authenticated write must come from this site; a bearer token is a client's own. */
|
|
77
|
+
const fromThisSiteOrBearer = (request: Request) => {
|
|
78
|
+
if (request.headers.get('authorization')?.toLowerCase().startsWith('bearer ')) return true
|
|
79
|
+
const site = request.headers.get('sec-fetch-site')
|
|
80
|
+
if (site) return site === 'same-origin'
|
|
81
|
+
const origin = request.headers.get('origin')
|
|
82
|
+
return !origin || origin === new URL(request.url).origin
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The media store key of a job's file. */
|
|
86
|
+
const jobFileKey = (id: string, name: string) => `.remote/${id}/${name}`
|
|
87
|
+
|
|
88
|
+
async function sha256Of(path: string): Promise<{ sha256: string; size: number }> {
|
|
89
|
+
const hasher = createHash('sha256')
|
|
90
|
+
let size = 0
|
|
91
|
+
for await (const chunk of Bun.file(path).stream()) {
|
|
92
|
+
hasher.update(chunk)
|
|
93
|
+
size += chunk.length
|
|
94
|
+
}
|
|
95
|
+
return { sha256: hasher.digest('hex'), size }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function createRemote(options: RemoteOptions): Remote {
|
|
99
|
+
const { db, config } = options
|
|
100
|
+
const settings = config.remote
|
|
101
|
+
const jobs = new RemoteJobRepository(db)
|
|
102
|
+
const secretKey = usableSecretKey(config.secretKey)
|
|
103
|
+
let storePromise: Promise<MediaStore> | undefined
|
|
104
|
+
const store = () => {
|
|
105
|
+
storePromise ??= mediaStoreFor(config)
|
|
106
|
+
return storePromise
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const environment = () => ({
|
|
110
|
+
id: settings.environment,
|
|
111
|
+
name: settings.environment,
|
|
112
|
+
protected: settings.protected,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const discovery = (origin: string) => ({
|
|
116
|
+
protocol: 'crombolo-remote',
|
|
117
|
+
versions: [REMOTE_PROTOCOL_VERSION],
|
|
118
|
+
name: config.siteName,
|
|
119
|
+
api: `${origin}${REMOTE_API_PATH}`,
|
|
120
|
+
auth: {
|
|
121
|
+
deviceAuthorization: `${origin}${MANAGEMENT_API_PATH}/security/back-office/device`,
|
|
122
|
+
token: `${origin}${MANAGEMENT_API_PATH}/security/back-office/token`,
|
|
123
|
+
clientId: REMOTE_CLIENT_ID,
|
|
124
|
+
grantType: DEVICE_CODE_GRANT,
|
|
125
|
+
},
|
|
126
|
+
capabilities: ['media-fetch', 'push-check', ...(settings.approvals ? ['approvals'] : [])],
|
|
127
|
+
environments: [environment()],
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const audit = (principal: Principal, request: Request, action: string, detail: string) =>
|
|
131
|
+
recordAudit(db, {
|
|
132
|
+
category: 'system',
|
|
133
|
+
action,
|
|
134
|
+
source: 'request',
|
|
135
|
+
userKey: principal.id,
|
|
136
|
+
userName: principal.name,
|
|
137
|
+
entityType: 'RemoteEnvironment',
|
|
138
|
+
entityName: settings.environment,
|
|
139
|
+
ipAddress: null,
|
|
140
|
+
forwardedFor: request.headers.get('x-forwarded-for'),
|
|
141
|
+
detail,
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
/** A job as the protocol shows it; a running one gone quiet is reported as failed. */
|
|
145
|
+
const describe = async (job: RemoteJob, origin: string) => {
|
|
146
|
+
let current = job
|
|
147
|
+
const live = job.state === 'running' || job.state === 'queued'
|
|
148
|
+
if (live && Date.now() - job.heartbeatAt.getTime() > STALE_SECONDS * 1000) {
|
|
149
|
+
const stopped: Problem = {
|
|
150
|
+
code: 'interrupted',
|
|
151
|
+
title: 'The job stopped',
|
|
152
|
+
detail: 'The server running it stopped before it finished. Start it again.',
|
|
153
|
+
}
|
|
154
|
+
await jobs.update(job.id, { state: 'failed', problem: stopped })
|
|
155
|
+
current = { ...job, state: 'failed', problem: stopped }
|
|
156
|
+
}
|
|
157
|
+
const done = current.state === 'done'
|
|
158
|
+
let download:
|
|
159
|
+
| { url: string; expiresAt: string; size: number | null; sha256: string | null }
|
|
160
|
+
| undefined
|
|
161
|
+
// Always through this API, never the store's public URL: a CDN in front of
|
|
162
|
+
// the media store would make a job's file world-readable.
|
|
163
|
+
if (done && current.kind === 'pull' && current.fileKey)
|
|
164
|
+
download = {
|
|
165
|
+
url: `${origin}${REMOTE_API_PATH}/jobs/${current.id}/download`,
|
|
166
|
+
expiresAt: current.expiresAt.toISOString(),
|
|
167
|
+
size: current.fileSize,
|
|
168
|
+
sha256: current.sha256,
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
id: current.id,
|
|
172
|
+
kind: current.kind,
|
|
173
|
+
environment: current.environment,
|
|
174
|
+
state: current.state,
|
|
175
|
+
stage: current.stage,
|
|
176
|
+
progress: current.progress,
|
|
177
|
+
requestedBy: current.requestedByName,
|
|
178
|
+
createdAt: current.createdAt.toISOString(),
|
|
179
|
+
...(current.state === 'approval-required'
|
|
180
|
+
? {
|
|
181
|
+
approvalUrl: `${origin}${config.backOfficePath}/remote-approval?job=${current.id}`,
|
|
182
|
+
approvalExpiresAt: current.expiresAt.toISOString(),
|
|
183
|
+
}
|
|
184
|
+
: {}),
|
|
185
|
+
...(download ? { download } : {}),
|
|
186
|
+
...(current.runId ? { runId: current.runId } : {}),
|
|
187
|
+
...(current.problem ? { problem: current.problem } : {}),
|
|
188
|
+
...(live || current.state === 'approval-required' ? { pollAfter: POLL_SECONDS } : {}),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Deletes jobs past their expiry, and their files. */
|
|
193
|
+
const sweep = async () => {
|
|
194
|
+
const media = await store()
|
|
195
|
+
for (const job of await jobs.expired()) {
|
|
196
|
+
if (job.state === 'approval-required')
|
|
197
|
+
await jobs.update(job.id, {
|
|
198
|
+
state: 'refused',
|
|
199
|
+
secret: null,
|
|
200
|
+
problem: { code: 'approval-expired', title: 'Nobody approved it in time' },
|
|
201
|
+
})
|
|
202
|
+
if (job.fileKey) await media.delete(job.fileKey).catch(() => {})
|
|
203
|
+
// A finished job's row outlives its file by a day, so a late poll still learns why.
|
|
204
|
+
if (Date.now() - job.expiresAt.getTime() > 24 * 60 * 60 * 1000) await jobs.delete(job.id)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Runs a job's work in the background, recording a failure as the job's state. */
|
|
209
|
+
const run = (id: string, work: () => Promise<void>) => {
|
|
210
|
+
void (async () => {
|
|
211
|
+
try {
|
|
212
|
+
await work()
|
|
213
|
+
} catch (error) {
|
|
214
|
+
await jobs
|
|
215
|
+
.update(id, {
|
|
216
|
+
state: 'failed',
|
|
217
|
+
problem: {
|
|
218
|
+
code: 'failed',
|
|
219
|
+
title: 'The job failed',
|
|
220
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
.catch(() => {})
|
|
224
|
+
}
|
|
225
|
+
})()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const pullWork = (job: RemoteJob, passphrase: string) => async () => {
|
|
229
|
+
const full = job.options.full === true
|
|
230
|
+
const media = job.options.media as 'included' | 'omitted' | 'referenced'
|
|
231
|
+
const out = join(tmpdir(), `crombolo-remote-${job.id}.tgz`)
|
|
232
|
+
try {
|
|
233
|
+
await jobs.update(job.id, { state: 'running', stage: 'exporting', progress: 10 })
|
|
234
|
+
await exportSite(db, config, {
|
|
235
|
+
out,
|
|
236
|
+
media: media === 'included' ? true : media === 'referenced' ? 'referenced' : false,
|
|
237
|
+
anonymise: !full,
|
|
238
|
+
withMembers: full,
|
|
239
|
+
withUsers: full,
|
|
240
|
+
sealContent: true,
|
|
241
|
+
passphrase,
|
|
242
|
+
})
|
|
243
|
+
await jobs.update(job.id, { stage: 'storing', progress: 80 })
|
|
244
|
+
const { sha256, size } = await sha256Of(out)
|
|
245
|
+
const key = jobFileKey(job.id, 'site.tgz')
|
|
246
|
+
await (await store()).put(key, Bun.file(out), { contentType: 'application/gzip' })
|
|
247
|
+
await jobs.update(job.id, {
|
|
248
|
+
state: 'done',
|
|
249
|
+
stage: 'ready',
|
|
250
|
+
progress: 100,
|
|
251
|
+
fileKey: key,
|
|
252
|
+
fileSize: size,
|
|
253
|
+
sha256,
|
|
254
|
+
expiresAt: new Date(Date.now() + JOB_FILE_SECONDS * 1000),
|
|
255
|
+
})
|
|
256
|
+
} finally {
|
|
257
|
+
await rm(out, { force: true })
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const pushWork = (jobId: string, passphrase: string, by: string) => async () => {
|
|
262
|
+
const job = (await jobs.get(jobId)) as RemoteJob
|
|
263
|
+
const local = join(tmpdir(), `crombolo-remote-${job.id}.tgz`)
|
|
264
|
+
const dir = join(config.siteDir, '.crombolo', 'transfer', 'incoming', `remote-${job.id}`)
|
|
265
|
+
try {
|
|
266
|
+
await jobs.update(job.id, {
|
|
267
|
+
state: 'running',
|
|
268
|
+
stage: 'unpacking',
|
|
269
|
+
progress: 10,
|
|
270
|
+
secret: null,
|
|
271
|
+
})
|
|
272
|
+
const media = await store()
|
|
273
|
+
const stored = job.fileKey ? await media.get(job.fileKey) : undefined
|
|
274
|
+
if (!stored) throw new Error('The uploaded file is no longer in the store.')
|
|
275
|
+
if (stored.localPath) await Bun.write(local, Bun.file(stored.localPath))
|
|
276
|
+
else {
|
|
277
|
+
const sink = createWriteStream(local)
|
|
278
|
+
for await (const chunk of streamOf(stored))
|
|
279
|
+
if (!sink.write(chunk)) await new Promise((r) => sink.once('drain', r))
|
|
280
|
+
await new Promise<void>((resolve, reject) =>
|
|
281
|
+
sink.end((error?: Error | null) => (error ? reject(error) : resolve())),
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
await rm(dir, { recursive: true, force: true })
|
|
285
|
+
await mkdir(dir, { recursive: true })
|
|
286
|
+
await extractTarball(local, dir)
|
|
287
|
+
await jobs.update(job.id, { stage: 'replacing', progress: 40 })
|
|
288
|
+
// A backup wherever this server can take one; on Postgres that needs CROMBOLO_PG_DUMP.
|
|
289
|
+
const canBackUp = config.dialect === 'sqlite' || Boolean(config.pgDump)
|
|
290
|
+
const result = await installBundleContent(db, config, {
|
|
291
|
+
bundle: dir,
|
|
292
|
+
replace: 'replace',
|
|
293
|
+
passphrase,
|
|
294
|
+
backupTaken: !canBackUp,
|
|
295
|
+
label: `push from ${by}`,
|
|
296
|
+
by,
|
|
297
|
+
})
|
|
298
|
+
if (!result.runId) {
|
|
299
|
+
const items = [
|
|
300
|
+
...result.problems,
|
|
301
|
+
...result.check.outstanding.map((finding) => finding.message),
|
|
302
|
+
]
|
|
303
|
+
await jobs.update(job.id, {
|
|
304
|
+
state: 'refused',
|
|
305
|
+
stage: null,
|
|
306
|
+
problem: { code: 'import-refused', title: 'The push was refused', items },
|
|
307
|
+
})
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
await jobs.update(job.id, {
|
|
311
|
+
state: 'done',
|
|
312
|
+
stage:
|
|
313
|
+
result.backup.kind === 'none' || result.backup.kind === 'operator'
|
|
314
|
+
? 'replaced, no backup'
|
|
315
|
+
: 'replaced',
|
|
316
|
+
progress: 100,
|
|
317
|
+
runId: result.runId,
|
|
318
|
+
expiresAt: new Date(Date.now() + JOB_FILE_SECONDS * 1000),
|
|
319
|
+
})
|
|
320
|
+
await media.delete(job.fileKey as string).catch(() => {})
|
|
321
|
+
} finally {
|
|
322
|
+
await rm(local, { force: true })
|
|
323
|
+
await rm(dir, { recursive: true, force: true })
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const passphraseProblem = (passphrase: string | null | undefined) =>
|
|
328
|
+
!passphrase || passphrase.length < MIN_PASSPHRASE
|
|
329
|
+
? problem(400, {
|
|
330
|
+
code: 'passphrase-required',
|
|
331
|
+
title: `A passphrase of at least ${MIN_PASSPHRASE} characters is required`,
|
|
332
|
+
detail: 'The file is sealed with it, so content travels unreadable.',
|
|
333
|
+
})
|
|
334
|
+
: undefined
|
|
335
|
+
|
|
336
|
+
async function startPull(request: Request, url: URL, principal: Principal): Promise<Response> {
|
|
337
|
+
if (!may(principal, RemotePermissions.Pull))
|
|
338
|
+
return problem(403, { code: 'forbidden', title: 'You may not pull from this environment' })
|
|
339
|
+
const body = (await request.json().catch(() => ({}))) as {
|
|
340
|
+
full?: unknown
|
|
341
|
+
media?: unknown
|
|
342
|
+
passphrase?: unknown
|
|
343
|
+
}
|
|
344
|
+
const full = body.full === true
|
|
345
|
+
if (full && !may(principal, RemotePermissions.PullFull))
|
|
346
|
+
return problem(403, {
|
|
347
|
+
code: 'forbidden',
|
|
348
|
+
title: 'You may not pull a full copy',
|
|
349
|
+
detail:
|
|
350
|
+
'A full copy carries people; it needs its own permission. Pull an anonymised copy instead.',
|
|
351
|
+
})
|
|
352
|
+
const passphrase = typeof body.passphrase === 'string' ? body.passphrase : ''
|
|
353
|
+
const refused = passphraseProblem(passphrase)
|
|
354
|
+
if (refused) return refused
|
|
355
|
+
const media = body.media === 'omitted' || body.media === 'referenced' ? body.media : 'included'
|
|
356
|
+
await sweep()
|
|
357
|
+
const job = {
|
|
358
|
+
id: crypto.randomUUID(),
|
|
359
|
+
kind: 'pull' as const,
|
|
360
|
+
environment: settings.environment,
|
|
361
|
+
requestedBy: principal.id,
|
|
362
|
+
requestedByName: principal.name,
|
|
363
|
+
options: { full, media },
|
|
364
|
+
state: 'queued' as const,
|
|
365
|
+
expiresAt: new Date(Date.now() + JOB_FILE_SECONDS * 1000),
|
|
366
|
+
}
|
|
367
|
+
await jobs.create(job)
|
|
368
|
+
await audit(principal, request, 'RemotePull', `${full ? 'full' : 'anonymised'}, media ${media}`)
|
|
369
|
+
const created = (await jobs.get(job.id)) as RemoteJob
|
|
370
|
+
run(job.id, pullWork(created, passphrase))
|
|
371
|
+
return Response.json(await describe(created, url.origin), { status: 202 })
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Streams a request body to a file, hashing it as it goes; undefined when it is not what it claimed. */
|
|
375
|
+
async function receive(
|
|
376
|
+
request: Request,
|
|
377
|
+
path: string,
|
|
378
|
+
): Promise<{ sha256: string; size: number } | undefined> {
|
|
379
|
+
if (!request.body) return undefined
|
|
380
|
+
const hasher = createHash('sha256')
|
|
381
|
+
let size = 0
|
|
382
|
+
const sink = createWriteStream(path)
|
|
383
|
+
const reader = request.body.getReader()
|
|
384
|
+
try {
|
|
385
|
+
for (;;) {
|
|
386
|
+
const { done, value } = await reader.read()
|
|
387
|
+
if (done) break
|
|
388
|
+
size += value.length
|
|
389
|
+
if (size > settings.maxUploadBytes) throw new Error('too large')
|
|
390
|
+
hasher.update(value)
|
|
391
|
+
if (!sink.write(value)) await new Promise((r) => sink.once('drain', r))
|
|
392
|
+
}
|
|
393
|
+
} finally {
|
|
394
|
+
await new Promise<void>((resolve) => sink.end(() => resolve()))
|
|
395
|
+
}
|
|
396
|
+
return { sha256: hasher.digest('hex'), size }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function startPush(request: Request, url: URL, principal: Principal): Promise<Response> {
|
|
400
|
+
if (!may(principal, RemotePermissions.Push))
|
|
401
|
+
return problem(403, { code: 'forbidden', title: 'You may not push to this environment' })
|
|
402
|
+
if (settings.protected && !may(principal, RemotePermissions.PushProtected))
|
|
403
|
+
return problem(403, {
|
|
404
|
+
code: 'forbidden-protected',
|
|
405
|
+
title: 'This environment is protected',
|
|
406
|
+
detail: 'Pushing here replaces something people rely on, and needs its own permission.',
|
|
407
|
+
})
|
|
408
|
+
const declared = request.headers.get('crombolo-sha256')?.toLowerCase()
|
|
409
|
+
const passphrase = request.headers.get('crombolo-passphrase')
|
|
410
|
+
const refused = passphraseProblem(passphrase)
|
|
411
|
+
if (refused) return refused
|
|
412
|
+
if (!declared || !/^[0-9a-f]{64}$/.test(declared))
|
|
413
|
+
return problem(400, { code: 'hash-required', title: 'Crombolo-Sha256 is required' })
|
|
414
|
+
if (settings.protected && settings.approvals && !secretKey)
|
|
415
|
+
return problem(503, {
|
|
416
|
+
code: 'approvals-unavailable',
|
|
417
|
+
title: 'Approvals need CROMBOLO_SECRET_KEY on the server',
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
await sweep()
|
|
421
|
+
const id = crypto.randomUUID()
|
|
422
|
+
const local = join(tmpdir(), `crombolo-remote-upload-${id}.tgz`)
|
|
423
|
+
try {
|
|
424
|
+
let received: { sha256: string; size: number } | undefined
|
|
425
|
+
try {
|
|
426
|
+
received = await receive(request, local)
|
|
427
|
+
} catch {
|
|
428
|
+
return problem(413, {
|
|
429
|
+
code: 'too-large',
|
|
430
|
+
title: 'The file is larger than this remote takes',
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
if (!received)
|
|
434
|
+
return problem(400, { code: 'body-required', title: 'The request carried no file' })
|
|
435
|
+
if (received.sha256 !== declared)
|
|
436
|
+
return problem(400, {
|
|
437
|
+
code: 'hash-mismatch',
|
|
438
|
+
title: 'The file is not the one described',
|
|
439
|
+
detail:
|
|
440
|
+
'Its SHA-256 does not match Crombolo-Sha256: it was changed or cut short on the way.',
|
|
441
|
+
})
|
|
442
|
+
const key = jobFileKey(id, 'upload.tgz')
|
|
443
|
+
await (await store()).put(key, Bun.file(local), { contentType: 'application/gzip' })
|
|
444
|
+
const waits = settings.protected && settings.approvals
|
|
445
|
+
await jobs.create({
|
|
446
|
+
id,
|
|
447
|
+
kind: 'push',
|
|
448
|
+
environment: settings.environment,
|
|
449
|
+
requestedBy: principal.id,
|
|
450
|
+
requestedByName: principal.name,
|
|
451
|
+
options: {},
|
|
452
|
+
state: waits ? 'approval-required' : 'queued',
|
|
453
|
+
expiresAt: new Date(Date.now() + (waits ? APPROVAL_SECONDS : JOB_FILE_SECONDS) * 1000),
|
|
454
|
+
})
|
|
455
|
+
await jobs.update(id, {
|
|
456
|
+
fileKey: key,
|
|
457
|
+
fileSize: received.size,
|
|
458
|
+
sha256: received.sha256,
|
|
459
|
+
...(waits && secretKey ? { secret: sealSecret(passphrase as string, secretKey) } : {}),
|
|
460
|
+
})
|
|
461
|
+
await audit(principal, request, 'RemotePush', waits ? 'waiting for approval' : 'started')
|
|
462
|
+
if (!waits) run(id, pushWork(id, passphrase as string, principal.name))
|
|
463
|
+
return Response.json(await describe((await jobs.get(id)) as RemoteJob, url.origin), {
|
|
464
|
+
status: 202,
|
|
465
|
+
})
|
|
466
|
+
} finally {
|
|
467
|
+
await rm(local, { force: true })
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function decide(
|
|
472
|
+
request: Request,
|
|
473
|
+
url: URL,
|
|
474
|
+
principal: Principal,
|
|
475
|
+
id: string,
|
|
476
|
+
approve: boolean,
|
|
477
|
+
): Promise<Response> {
|
|
478
|
+
if (!fromThisSiteOrBearer(request)) return new Response('Forbidden', { status: 403 })
|
|
479
|
+
const job = await jobs.get(id)
|
|
480
|
+
if (job?.kind !== 'push')
|
|
481
|
+
return problem(404, { code: 'not-found', title: 'There is no such push' })
|
|
482
|
+
if (job.state !== 'approval-required')
|
|
483
|
+
return problem(409, { code: 'not-waiting', title: 'This push is not waiting for approval' })
|
|
484
|
+
if (!may(principal, RemotePermissions.PushProtected))
|
|
485
|
+
return problem(403, { code: 'forbidden', title: 'You may not approve pushes here' })
|
|
486
|
+
if (job.requestedBy === principal.id)
|
|
487
|
+
return problem(403, {
|
|
488
|
+
code: 'own-push',
|
|
489
|
+
title: 'Someone else has to approve your push',
|
|
490
|
+
})
|
|
491
|
+
if (job.expiresAt.getTime() < Date.now()) {
|
|
492
|
+
await sweep()
|
|
493
|
+
return problem(409, { code: 'approval-expired', title: 'This push waited too long' })
|
|
494
|
+
}
|
|
495
|
+
const moved = await jobs.transition(id, 'approval-required', {
|
|
496
|
+
state: approve ? 'queued' : 'refused',
|
|
497
|
+
decidedBy: principal.id,
|
|
498
|
+
decidedAt: new Date(),
|
|
499
|
+
...(approve
|
|
500
|
+
? { expiresAt: new Date(Date.now() + JOB_FILE_SECONDS * 1000) }
|
|
501
|
+
: { secret: null, problem: { code: 'declined', title: `${principal.name} declined it` } }),
|
|
502
|
+
})
|
|
503
|
+
if (!moved) return problem(409, { code: 'not-waiting', title: 'Someone else decided it first' })
|
|
504
|
+
await audit(
|
|
505
|
+
principal,
|
|
506
|
+
request,
|
|
507
|
+
approve ? 'RemotePushApproved' : 'RemotePushDeclined',
|
|
508
|
+
job.requestedByName,
|
|
509
|
+
)
|
|
510
|
+
if (approve && job.secret && secretKey)
|
|
511
|
+
run(id, pushWork(id, openSecret(job.secret, secretKey), job.requestedByName))
|
|
512
|
+
return Response.json(await describe((await jobs.get(id)) as RemoteJob, url.origin))
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* What a push would do here, from the file's node keys and section paths
|
|
517
|
+
* alone — no upload, nothing written (`push-check`, story 108). Paths are
|
|
518
|
+
* answered by section, never as this server's own.
|
|
519
|
+
*/
|
|
520
|
+
async function pushCheck(request: Request): Promise<Response> {
|
|
521
|
+
const body = (await request.json().catch(() => ({}))) as { nodes?: unknown; files?: unknown }
|
|
522
|
+
const strings = (value: unknown) =>
|
|
523
|
+
Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
|
|
524
|
+
const nodes = strings(body.nodes)
|
|
525
|
+
const files = strings(body.files)
|
|
526
|
+
const folders = [
|
|
527
|
+
{ section: 'schema', dir: config.schemaDir },
|
|
528
|
+
{ section: 'components', dir: config.componentsDir },
|
|
529
|
+
{ section: 'styles', dir: config.stylesheetsDir },
|
|
530
|
+
{ section: 'scripts', dir: config.scriptsDir },
|
|
531
|
+
]
|
|
532
|
+
const summary = await replaceSummary(
|
|
533
|
+
db,
|
|
534
|
+
{ nodes: nodes.map((key) => ({ key })) } as unknown as ContentSet,
|
|
535
|
+
new Set(files),
|
|
536
|
+
folders,
|
|
537
|
+
'replace',
|
|
538
|
+
)
|
|
539
|
+
const relative = (path: string) => {
|
|
540
|
+
const folder = folders.find((f) => path.startsWith(`${f.dir}${sep}`))
|
|
541
|
+
return folder
|
|
542
|
+
? `${folder.section}/${path
|
|
543
|
+
.slice(folder.dir.length + 1)
|
|
544
|
+
.split(sep)
|
|
545
|
+
.join('/')}`
|
|
546
|
+
: path
|
|
547
|
+
}
|
|
548
|
+
return Response.json({
|
|
549
|
+
environment: settings.environment,
|
|
550
|
+
recycles: summary.leaving,
|
|
551
|
+
removesFiles: summary.filesRemoved.map(relative).sort(),
|
|
552
|
+
keepsPeople: true,
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Short-lived links to media files, for a pull that carries their metadata only. */
|
|
557
|
+
async function mediaLinks(request: Request, url: URL): Promise<Response> {
|
|
558
|
+
const body = (await request.json().catch(() => ({}))) as { keys?: unknown }
|
|
559
|
+
const keys = Array.isArray(body.keys)
|
|
560
|
+
? body.keys.filter((k): k is string => typeof k === 'string')
|
|
561
|
+
: []
|
|
562
|
+
if (keys.length > MAX_LINKS)
|
|
563
|
+
return problem(400, { code: 'too-many', title: `At most ${MAX_LINKS} keys at a time` })
|
|
564
|
+
const media = await store()
|
|
565
|
+
const links: Record<string, { url: string; size: number; etag: string } | null> = {}
|
|
566
|
+
for (const key of keys) {
|
|
567
|
+
const found = await media.get(key)
|
|
568
|
+
links[key] = found
|
|
569
|
+
? {
|
|
570
|
+
url:
|
|
571
|
+
media.publicUrl?.(key) ??
|
|
572
|
+
`${url.origin}${REMOTE_API_PATH}/environments/${settings.environment}/media/${key
|
|
573
|
+
.split('/')
|
|
574
|
+
.map(encodeURIComponent)
|
|
575
|
+
.join('/')}`,
|
|
576
|
+
size: found.size,
|
|
577
|
+
etag: found.etag,
|
|
578
|
+
}
|
|
579
|
+
: null
|
|
580
|
+
}
|
|
581
|
+
return Response.json({ links })
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function mediaHave(request: Request): Promise<Response> {
|
|
585
|
+
const body = (await request.json().catch(() => ({}))) as { blobs?: unknown }
|
|
586
|
+
const blobs = Array.isArray(body.blobs) ? body.blobs.slice(0, MAX_LINKS * 10) : []
|
|
587
|
+
const media = await store()
|
|
588
|
+
const have: string[] = []
|
|
589
|
+
for (const blob of blobs) {
|
|
590
|
+
if (!blob || typeof blob !== 'object') continue
|
|
591
|
+
const { key, etag } = blob as { key?: unknown; etag?: unknown }
|
|
592
|
+
if (typeof key !== 'string') continue
|
|
593
|
+
const found = await media.get(key)
|
|
594
|
+
if (found && (typeof etag !== 'string' || found.etag === etag)) have.push(key)
|
|
595
|
+
}
|
|
596
|
+
return Response.json({ have })
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function mediaGet(key: string): Promise<Response> {
|
|
600
|
+
const found = await (await store()).get(key)
|
|
601
|
+
if (!found) return problem(404, { code: 'not-found', title: 'There is no such media file' })
|
|
602
|
+
if (found.localPath)
|
|
603
|
+
return new Response(Bun.file(found.localPath), {
|
|
604
|
+
headers: { 'content-type': found.contentType, etag: found.etag },
|
|
605
|
+
})
|
|
606
|
+
const stream = found.stream
|
|
607
|
+
? await found.stream()
|
|
608
|
+
: new Blob([new Uint8Array(await found.bytes())]).stream()
|
|
609
|
+
return new Response(stream, {
|
|
610
|
+
headers: {
|
|
611
|
+
'content-type': found.contentType,
|
|
612
|
+
'content-length': String(found.size),
|
|
613
|
+
etag: found.etag,
|
|
614
|
+
},
|
|
615
|
+
})
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function mediaPut(request: Request, key: string): Promise<Response> {
|
|
619
|
+
const declared = request.headers.get('crombolo-sha256')?.toLowerCase()
|
|
620
|
+
if (!declared)
|
|
621
|
+
return problem(400, { code: 'hash-required', title: 'Crombolo-Sha256 is required' })
|
|
622
|
+
const local = join(tmpdir(), `crombolo-remote-media-${crypto.randomUUID()}`)
|
|
623
|
+
try {
|
|
624
|
+
let received: { sha256: string; size: number } | undefined
|
|
625
|
+
try {
|
|
626
|
+
received = await receive(request, local)
|
|
627
|
+
} catch {
|
|
628
|
+
return problem(413, {
|
|
629
|
+
code: 'too-large',
|
|
630
|
+
title: 'The file is larger than this remote takes',
|
|
631
|
+
})
|
|
632
|
+
}
|
|
633
|
+
if (!received)
|
|
634
|
+
return problem(400, { code: 'body-required', title: 'The request carried no file' })
|
|
635
|
+
if (received.sha256 !== declared)
|
|
636
|
+
return problem(400, { code: 'hash-mismatch', title: 'The file is not the one described' })
|
|
637
|
+
await (await store()).put(key, Bun.file(local))
|
|
638
|
+
return new Response(null, { status: 204 })
|
|
639
|
+
} finally {
|
|
640
|
+
await rm(local, { force: true })
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function download(id: string, principal: Principal): Promise<Response> {
|
|
645
|
+
const job = await jobs.get(id)
|
|
646
|
+
if (job?.kind !== 'pull' || job.requestedBy !== principal.id)
|
|
647
|
+
return problem(404, { code: 'not-found', title: 'There is no such download' })
|
|
648
|
+
if (job.state !== 'done' || !job.fileKey || job.expiresAt.getTime() < Date.now())
|
|
649
|
+
return problem(410, { code: 'gone', title: 'This download has expired' })
|
|
650
|
+
const found = await (await store()).get(job.fileKey)
|
|
651
|
+
if (!found) return problem(410, { code: 'gone', title: 'This download has expired' })
|
|
652
|
+
const body = found.localPath
|
|
653
|
+
? Bun.file(found.localPath)
|
|
654
|
+
: found.stream
|
|
655
|
+
? await found.stream()
|
|
656
|
+
: new Uint8Array(await found.bytes())
|
|
657
|
+
return new Response(body as BodyInit, {
|
|
658
|
+
headers: {
|
|
659
|
+
'content-type': 'application/gzip',
|
|
660
|
+
'content-length': String(found.size),
|
|
661
|
+
'content-disposition': `attachment; filename="${settings.environment}.tgz"`,
|
|
662
|
+
},
|
|
663
|
+
})
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const mediaKeyOf = (rest: string): string | undefined => {
|
|
667
|
+
try {
|
|
668
|
+
const key = rest.split('/').map(decodeURIComponent).join('/')
|
|
669
|
+
return key && !key.split('/').includes('..') && !key.startsWith('.remote/') ? key : undefined
|
|
670
|
+
} catch {
|
|
671
|
+
return undefined
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return {
|
|
676
|
+
isUpload(request, url) {
|
|
677
|
+
if (!settings.enabled || request.method !== 'PUT') return false
|
|
678
|
+
const prefix = `${REMOTE_API_PATH}/environments/${settings.environment}/`
|
|
679
|
+
return url.pathname === `${prefix}pushes` || url.pathname.startsWith(`${prefix}media/`)
|
|
680
|
+
},
|
|
681
|
+
|
|
682
|
+
async handle(request, url) {
|
|
683
|
+
const { pathname } = url
|
|
684
|
+
if (pathname === REMOTE_DISCOVERY_PATH) {
|
|
685
|
+
if (!settings.enabled) return undefined
|
|
686
|
+
return request.method === 'GET'
|
|
687
|
+
? Response.json(discovery(url.origin), { headers: { 'cache-control': 'no-store' } })
|
|
688
|
+
: new Response(null, { status: 405 })
|
|
689
|
+
}
|
|
690
|
+
if (!pathname.startsWith(`${REMOTE_API_PATH}/`)) return undefined
|
|
691
|
+
if (!settings.enabled)
|
|
692
|
+
return problem(404, { code: 'not-enabled', title: 'This site is not a remote' })
|
|
693
|
+
const principal = await options.authenticate(request)
|
|
694
|
+
if (!principal)
|
|
695
|
+
return problem(401, {
|
|
696
|
+
code: 'sign-in-required',
|
|
697
|
+
title: 'Sign in first',
|
|
698
|
+
detail: 'Sign in with the device grant the discovery document names.',
|
|
699
|
+
})
|
|
700
|
+
const rest = pathname.slice(REMOTE_API_PATH.length + 1)
|
|
701
|
+
|
|
702
|
+
if (rest === 'environments' && request.method === 'GET')
|
|
703
|
+
return Response.json([
|
|
704
|
+
{
|
|
705
|
+
...environment(),
|
|
706
|
+
may: {
|
|
707
|
+
pull: may(principal, RemotePermissions.Pull),
|
|
708
|
+
pullFull: may(principal, RemotePermissions.PullFull),
|
|
709
|
+
push:
|
|
710
|
+
may(principal, RemotePermissions.Push) &&
|
|
711
|
+
(!settings.protected || may(principal, RemotePermissions.PushProtected)),
|
|
712
|
+
},
|
|
713
|
+
},
|
|
714
|
+
])
|
|
715
|
+
|
|
716
|
+
const job = /^jobs\/([0-9a-f-]{36})(?:\/(download|approve|decline))?$/.exec(rest)
|
|
717
|
+
if (job) {
|
|
718
|
+
const [, id, action] = job as unknown as [string, string, string | undefined]
|
|
719
|
+
if (!action && request.method === 'GET') {
|
|
720
|
+
const found = await jobs.get(id)
|
|
721
|
+
// A job is its requester's, and an approver's to look at.
|
|
722
|
+
if (
|
|
723
|
+
!found ||
|
|
724
|
+
(found.requestedBy !== principal.id && !may(principal, RemotePermissions.PushProtected))
|
|
725
|
+
)
|
|
726
|
+
return problem(404, { code: 'not-found', title: 'There is no such job' })
|
|
727
|
+
return Response.json(await describe(found, url.origin))
|
|
728
|
+
}
|
|
729
|
+
if (action === 'download' && request.method === 'GET') return download(id, principal)
|
|
730
|
+
if ((action === 'approve' || action === 'decline') && request.method === 'POST')
|
|
731
|
+
return decide(request, url, principal, id, action === 'approve')
|
|
732
|
+
return new Response(null, { status: 405 })
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const env = /^environments\/([a-z][a-z0-9-]*)\/(.+)$/.exec(rest)
|
|
736
|
+
if (!env) return problem(404, { code: 'not-found', title: 'There is nothing here' })
|
|
737
|
+
const [, envId, what] = env as unknown as [string, string, string]
|
|
738
|
+
if (envId !== settings.environment)
|
|
739
|
+
return problem(404, {
|
|
740
|
+
code: 'no-such-environment',
|
|
741
|
+
title: `There is no environment "${envId}"`,
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
if (what === 'pulls' && request.method === 'POST') return startPull(request, url, principal)
|
|
745
|
+
if (what === 'pushes' && request.method === 'PUT') return startPush(request, url, principal)
|
|
746
|
+
if (what === 'pushes/check' && request.method === 'POST') {
|
|
747
|
+
if (
|
|
748
|
+
!may(principal, RemotePermissions.Push) ||
|
|
749
|
+
(settings.protected && !may(principal, RemotePermissions.PushProtected))
|
|
750
|
+
)
|
|
751
|
+
return problem(403, { code: 'forbidden', title: 'You may not push to this environment' })
|
|
752
|
+
return pushCheck(request)
|
|
753
|
+
}
|
|
754
|
+
if (what === 'media/links' && request.method === 'POST') {
|
|
755
|
+
if (!may(principal, RemotePermissions.Pull))
|
|
756
|
+
return problem(403, {
|
|
757
|
+
code: 'forbidden',
|
|
758
|
+
title: 'You may not pull from this environment',
|
|
759
|
+
})
|
|
760
|
+
return mediaLinks(request, url)
|
|
761
|
+
}
|
|
762
|
+
if (what === 'media/have' && request.method === 'POST') {
|
|
763
|
+
if (!may(principal, RemotePermissions.Push))
|
|
764
|
+
return problem(403, { code: 'forbidden', title: 'You may not push to this environment' })
|
|
765
|
+
return mediaHave(request)
|
|
766
|
+
}
|
|
767
|
+
if (what.startsWith('media/')) {
|
|
768
|
+
const key = mediaKeyOf(what.slice('media/'.length))
|
|
769
|
+
if (!key) return problem(400, { code: 'bad-key', title: 'That is not a media key' })
|
|
770
|
+
if (request.method === 'GET') {
|
|
771
|
+
if (!may(principal, RemotePermissions.Pull))
|
|
772
|
+
return problem(403, {
|
|
773
|
+
code: 'forbidden',
|
|
774
|
+
title: 'You may not pull from this environment',
|
|
775
|
+
})
|
|
776
|
+
return mediaGet(key)
|
|
777
|
+
}
|
|
778
|
+
if (request.method === 'PUT') {
|
|
779
|
+
if (
|
|
780
|
+
!may(principal, RemotePermissions.Push) ||
|
|
781
|
+
(settings.protected && !may(principal, RemotePermissions.PushProtected))
|
|
782
|
+
)
|
|
783
|
+
return problem(403, {
|
|
784
|
+
code: 'forbidden',
|
|
785
|
+
title: 'You may not push to this environment',
|
|
786
|
+
})
|
|
787
|
+
return mediaPut(request, key)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return new Response(null, { status: 405 })
|
|
791
|
+
},
|
|
792
|
+
}
|
|
793
|
+
}
|