@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/server.ts
CHANGED
|
@@ -31,7 +31,9 @@ import {
|
|
|
31
31
|
createExtensionRegistry,
|
|
32
32
|
isSpaRoute,
|
|
33
33
|
renderBackOfficeShell,
|
|
34
|
+
renderDeviceShell,
|
|
34
35
|
renderLoginShell,
|
|
36
|
+
renderRemoteApprovalShell,
|
|
35
37
|
resolveGraphic,
|
|
36
38
|
resolveLoginAsset,
|
|
37
39
|
resolveStaticFile,
|
|
@@ -95,7 +97,13 @@ import { CommandError, installBundleContent } from './bundle-install.ts'
|
|
|
95
97
|
import { bundleChains, bundleTablesReport } from './bundle-tables.ts'
|
|
96
98
|
import { createBundleRedirects, createServerBundles, validateServerBundles } from './bundles.ts'
|
|
97
99
|
import { busyResponse } from './busy.ts'
|
|
98
|
-
import {
|
|
100
|
+
import {
|
|
101
|
+
type CromboloConfig,
|
|
102
|
+
listeningBodyLimit,
|
|
103
|
+
loadConfig,
|
|
104
|
+
type ServerRole,
|
|
105
|
+
VERSION,
|
|
106
|
+
} from './config.ts'
|
|
99
107
|
import { attachDatabase, bootstrapDatabase } from './database.ts'
|
|
100
108
|
import { createDeliveryMemberSecurity } from './delivery-members.ts'
|
|
101
109
|
import { createDiagnostics } from './diagnostics.ts'
|
|
@@ -136,6 +144,8 @@ import { noNodesPage } from './no-nodes.ts'
|
|
|
136
144
|
import { NotImplementedLog } from './not-implemented.ts'
|
|
137
145
|
import { createDeps, PREVIEW_COOKIE, resolveUserLinkSender } from './ports.ts'
|
|
138
146
|
import { createRedirectTracker } from './redirects.ts'
|
|
147
|
+
import { createRemote } from './remote.ts'
|
|
148
|
+
import { withRemoteMedia } from './remote-media.ts'
|
|
139
149
|
import { handleTransferAction, pendingTransfers } from './transfer-dashboard.ts'
|
|
140
150
|
import {
|
|
141
151
|
changeUrlOptions,
|
|
@@ -656,6 +666,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
656
666
|
},
|
|
657
667
|
)
|
|
658
668
|
: undefined
|
|
669
|
+
const remote = createRemote({ db, config, authenticate })
|
|
659
670
|
const members = createMemberAuth(db, config, {
|
|
660
671
|
nodeState: schema.nodeState,
|
|
661
672
|
nodeId: schema.nodeId,
|
|
@@ -728,7 +739,11 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
728
739
|
})
|
|
729
740
|
// The site's config may name a store; without one the environment decides,
|
|
730
741
|
// and that falls back to the media directory on this disk.
|
|
731
|
-
|
|
742
|
+
// A site pulled with `--lazy-media` reads files through from its remote (story 108).
|
|
743
|
+
const mediaStore = withRemoteMedia(
|
|
744
|
+
config.mediaStore ?? (await mediaStoreFromEnvironment(config.mediaDir)),
|
|
745
|
+
config.siteDir,
|
|
746
|
+
)
|
|
732
747
|
// Resolved once: constructing it twice would warn twice about a provider that
|
|
733
748
|
// is half-configured, and the adapters are cheap but not free of side effects.
|
|
734
749
|
const email = resolveEmailPort(config)
|
|
@@ -968,6 +983,12 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
968
983
|
if (response) return response
|
|
969
984
|
}
|
|
970
985
|
|
|
986
|
+
// The remote protocol (story 107), where the backoffice is: it acts as a person.
|
|
987
|
+
if (servesBackOffice) {
|
|
988
|
+
const response = await remote.handle(request, url)
|
|
989
|
+
if (response) return response
|
|
990
|
+
}
|
|
991
|
+
|
|
971
992
|
if (servesBackOffice) {
|
|
972
993
|
const response = await serveBackOffice(request, url)
|
|
973
994
|
if (response) {
|
|
@@ -1698,6 +1719,51 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1698
1719
|
return api.dispatch(request)
|
|
1699
1720
|
}
|
|
1700
1721
|
|
|
1722
|
+
// Approving a command-line sign-in (story 107): signed in first, then back here.
|
|
1723
|
+
if (pathname === `${paths.backOfficePath}/device` && request.method === 'GET') {
|
|
1724
|
+
const sessionId = readCookie(request, names.session)
|
|
1725
|
+
if (!sessionId || !(await auth.resolveSession(sessionId))) {
|
|
1726
|
+
const back = `${pathname}${url.search}`
|
|
1727
|
+
return new Response(null, {
|
|
1728
|
+
status: 303,
|
|
1729
|
+
headers: {
|
|
1730
|
+
location: `${paths.backOfficePath}/login?returnUrl=${encodeURIComponent(back)}`,
|
|
1731
|
+
},
|
|
1732
|
+
})
|
|
1733
|
+
}
|
|
1734
|
+
const { importmap } = collectManifests(paths, extensions.list())
|
|
1735
|
+
return html(
|
|
1736
|
+
renderDeviceShell(paths, importmap, {
|
|
1737
|
+
title: config.siteName,
|
|
1738
|
+
userCode: url.searchParams.get('user_code') ?? undefined,
|
|
1739
|
+
}),
|
|
1740
|
+
)
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// Approving a push to a protected environment (story 107): the page reads
|
|
1744
|
+
// and decides the job through the remote API, as the signed-in person.
|
|
1745
|
+
if (
|
|
1746
|
+
pathname === `${paths.backOfficePath}/remote-approval` &&
|
|
1747
|
+
request.method === 'GET' &&
|
|
1748
|
+
config.remote.enabled
|
|
1749
|
+
) {
|
|
1750
|
+
const sessionId = readCookie(request, names.session)
|
|
1751
|
+
if (!sessionId || !(await auth.resolveSession(sessionId)))
|
|
1752
|
+
return new Response(null, {
|
|
1753
|
+
status: 303,
|
|
1754
|
+
headers: {
|
|
1755
|
+
location: `${paths.backOfficePath}/login?returnUrl=${encodeURIComponent(`${pathname}${url.search}`)}`,
|
|
1756
|
+
},
|
|
1757
|
+
})
|
|
1758
|
+
const { importmap } = collectManifests(paths, extensions.list())
|
|
1759
|
+
return html(
|
|
1760
|
+
renderRemoteApprovalShell(paths, importmap, {
|
|
1761
|
+
title: config.siteName,
|
|
1762
|
+
job: url.searchParams.get('job') ?? '',
|
|
1763
|
+
}),
|
|
1764
|
+
)
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1701
1767
|
if (pathname === `${paths.backOfficePath}/login`) {
|
|
1702
1768
|
const { importmap } = collectManifests(paths, extensions.list())
|
|
1703
1769
|
return html(
|
|
@@ -2108,11 +2174,24 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
2108
2174
|
jobs,
|
|
2109
2175
|
serveOptions: {
|
|
2110
2176
|
port: config.port,
|
|
2111
|
-
maxRequestBodySize: config
|
|
2177
|
+
maxRequestBodySize: listeningBodyLimit(config),
|
|
2112
2178
|
fetch: diagnostics.wrap(async (request, bunServer: Server<HubSocketData>) => {
|
|
2113
2179
|
const peer = bunServer.requestIP(request)?.address
|
|
2114
2180
|
if (peer) peers.set(request, peer)
|
|
2115
|
-
const
|
|
2181
|
+
const requestUrl = new URL(request.url)
|
|
2182
|
+
const pathname = requestUrl.pathname
|
|
2183
|
+
// Listening with the remote's larger limit (story 107), so every other
|
|
2184
|
+
// request is held to the usual one here.
|
|
2185
|
+
if (
|
|
2186
|
+
listeningBodyLimit(config) > config.maxRequestBodyBytes &&
|
|
2187
|
+
!remote.isUpload(request, requestUrl)
|
|
2188
|
+
) {
|
|
2189
|
+
const length = Number(request.headers.get('content-length') ?? 0)
|
|
2190
|
+
if (length > config.maxRequestBodyBytes)
|
|
2191
|
+
return new Response('Payload Too Large', { status: 413 })
|
|
2192
|
+
if (!request.headers.has('content-length') && request.headers.has('transfer-encoding'))
|
|
2193
|
+
return new Response('Length Required', { status: 411 })
|
|
2194
|
+
}
|
|
2116
2195
|
if (pathname === hubPath || pathname === previewHubPath) {
|
|
2117
2196
|
const principal = await authenticate(request)
|
|
2118
2197
|
const hub = pathname === hubPath ? events : previewHub
|
package/src/site-file.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
packageNameFor,
|
|
28
28
|
planStreamedBundle,
|
|
29
29
|
sanitise,
|
|
30
|
+
sealBytes,
|
|
30
31
|
writeManifest,
|
|
31
32
|
} from '@crombolo/transfer'
|
|
32
33
|
import { createFileSystemPort } from './adapters/file-system.ts'
|
|
@@ -40,15 +41,25 @@ import { TarballWriter } from './tar.ts'
|
|
|
40
41
|
export interface SiteExportOptions {
|
|
41
42
|
/** The `.tgz` to write. */
|
|
42
43
|
out: string
|
|
43
|
-
/**
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* False for `--no-media`: the media nodes travel, their files do not.
|
|
46
|
+
* `referenced`: likewise, for a remote and its CLI to move the files one at a
|
|
47
|
+
* time (story 107).
|
|
48
|
+
*/
|
|
49
|
+
media?: boolean | 'referenced'
|
|
45
50
|
/** Carry the latest drafts rather than what is published. */
|
|
46
51
|
drafts?: boolean
|
|
47
52
|
withMembers?: boolean
|
|
48
53
|
withUsers?: boolean
|
|
49
54
|
/** Replace every personal value before anything is written (`sanitise`). */
|
|
50
55
|
anonymise?: boolean
|
|
51
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Seal the content data — property values and media files — as well as any
|
|
58
|
+
* people, under `passphrase`: the bundle rule, metadata readable and content
|
|
59
|
+
* not (story 107). Each media file is sealed whole, one at a time.
|
|
60
|
+
*/
|
|
61
|
+
sealContent?: boolean
|
|
62
|
+
/** Seals the people, and the content with `sealContent`; needed when either travels sealed. */
|
|
52
63
|
passphrase?: string
|
|
53
64
|
package?: { name?: string; version?: string }
|
|
54
65
|
}
|
|
@@ -101,12 +112,14 @@ export async function exportSite(
|
|
|
101
112
|
config: CromboloConfig,
|
|
102
113
|
options: SiteExportOptions,
|
|
103
114
|
): Promise<SiteExportResult> {
|
|
104
|
-
const withMedia = options.media !== false
|
|
115
|
+
const withMedia = options.media !== false && options.media !== 'referenced'
|
|
105
116
|
const carryMembers = Boolean(options.withMembers)
|
|
106
117
|
// Anonymised users are removed, so there are none to carry.
|
|
107
118
|
const carryUsers = Boolean(options.withUsers) && !options.anonymise
|
|
108
119
|
if ((carryMembers || carryUsers) && !options.passphrase)
|
|
109
120
|
throw new Error('Members and users travel sealed: give a passphrase.')
|
|
121
|
+
if (options.sealContent && !options.passphrase)
|
|
122
|
+
throw new Error('Sealed content needs a passphrase.')
|
|
110
123
|
|
|
111
124
|
const exported = await exportBundle(db, {
|
|
112
125
|
roots: await topLevelNodes(db),
|
|
@@ -158,30 +171,43 @@ export async function exportSite(
|
|
|
158
171
|
const store = await mediaStoreFor(config)
|
|
159
172
|
const stored = new Map<string, StoredFile>()
|
|
160
173
|
const missing: string[] = []
|
|
161
|
-
|
|
174
|
+
// Referenced, each file is named with its size and etag, so the far side can
|
|
175
|
+
// fetch it — or skip it, holding one with the same etag already.
|
|
176
|
+
const described = new Map<string, { size: number; etag: string }>()
|
|
177
|
+
if (withMedia || options.media === 'referenced')
|
|
162
178
|
for (const key of exported.blobKeys) {
|
|
163
179
|
const file = await store.get(key)
|
|
164
|
-
if (file)
|
|
165
|
-
else
|
|
180
|
+
if (!file) missing.push(key)
|
|
181
|
+
else if (withMedia) stored.set(key, file)
|
|
182
|
+
else described.set(key, { size: file.size, etag: file.etag })
|
|
166
183
|
}
|
|
167
184
|
|
|
168
185
|
const site = {
|
|
169
186
|
...set,
|
|
170
187
|
manifest: {
|
|
171
188
|
...set.manifest,
|
|
189
|
+
blobs: set.manifest.blobs.map((blob) => {
|
|
190
|
+
const found = described.get(blob.key)
|
|
191
|
+
return found ? { ...blob, size: found.size, etag: found.etag } : blob
|
|
192
|
+
}),
|
|
172
193
|
label: `${config.siteName} (whole site)`,
|
|
173
194
|
provenance: { ...set.manifest.provenance, cmsVersion: VERSION },
|
|
174
195
|
scope: 'site' as const,
|
|
175
|
-
...(withMedia
|
|
196
|
+
...(withMedia
|
|
197
|
+
? {}
|
|
198
|
+
: {
|
|
199
|
+
media: options.media === 'referenced' ? ('referenced' as const) : ('omitted' as const),
|
|
200
|
+
}),
|
|
176
201
|
requires: requiredPackages(config),
|
|
177
202
|
},
|
|
178
203
|
}
|
|
179
|
-
const seal =
|
|
204
|
+
const seal =
|
|
205
|
+
(people || options.sealContent) && options.passphrase ? newSeal(options.passphrase) : undefined
|
|
180
206
|
const planned = planStreamedBundle(
|
|
181
207
|
site,
|
|
182
208
|
new Map([...stored].map(([key, file]) => [key, file.size])),
|
|
183
209
|
sections,
|
|
184
|
-
{ people, seal },
|
|
210
|
+
{ people, seal, content: options.sealContent ? 'sealed' : 'open' },
|
|
185
211
|
)
|
|
186
212
|
|
|
187
213
|
const name = options.package?.name ?? packageNameFor(`${config.siteName}-site`)
|
|
@@ -213,6 +239,14 @@ export async function exportSite(
|
|
|
213
239
|
await writer.add(`${PAYLOAD_DIR}/${path}`, body)
|
|
214
240
|
} else {
|
|
215
241
|
const media = stored.get(blobs.get(path) as string) as StoredFile
|
|
242
|
+
if (planned.sealing && seal) {
|
|
243
|
+
const sealed = sealBytes(seal.key, path, await media.bytes())
|
|
244
|
+
hasher.update(sealed)
|
|
245
|
+
hasher.update('\n')
|
|
246
|
+
await writer.add(`${PAYLOAD_DIR}/${path}`, sealed)
|
|
247
|
+
bytes += media.size
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
216
250
|
async function* hashed() {
|
|
217
251
|
for await (const chunk of streamOf(media)) {
|
|
218
252
|
hasher.update(chunk)
|
|
@@ -233,7 +267,7 @@ export async function exportSite(
|
|
|
233
267
|
await writer.close()
|
|
234
268
|
await rename(partial, options.out)
|
|
235
269
|
} catch (error) {
|
|
236
|
-
await writer.
|
|
270
|
+
await writer.abort()
|
|
237
271
|
await rm(partial, { force: true })
|
|
238
272
|
throw error
|
|
239
273
|
}
|
package/src/tar.ts
CHANGED
|
@@ -148,6 +148,15 @@ export class TarballWriter {
|
|
|
148
148
|
this.#gzip.end()
|
|
149
149
|
await this.#done
|
|
150
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
|
+
}
|
|
151
160
|
}
|
|
152
161
|
|
|
153
162
|
const decoder = new TextDecoder()
|