@formancy/server 0.6.0 → 0.8.0
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/dist/{completers-C-E2b7Jm.mjs → completers-CQQDS4qr.mjs} +12 -740
- package/dist/completers-CQQDS4qr.mjs.map +1 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/main.mjs +1 -281
- package/dist/main.mjs.map +1 -1
- package/package.json +4 -4
- package/dist/completers-C-E2b7Jm.mjs.map +0 -1
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/model-settings.ts","../src/routes/model.ts","../src/file-store.ts","../src/server-log.ts","../src/headers.ts","../src/app.ts","../src/postgres-storage.ts","../src/postgres-rate-limits.ts","../src/db.ts","../src/file-collector.ts","../src/completers.ts"],"mappings":";;;;;;;;;;;;;;;qBASa;;KAGD,wBAAwB;KAExB;WACG;;WAEA;WACA,UAAU;WACV;WACA;;KAGV,cAAc,SAAS;;;;;;;;;;;;;;;wBA8BZ,cAAc,KAAK,cAAc;;;;UCrChC;WACN,UAAU;WACV;WACA,WAAW;;;;;;;;;;;;;;;;;;UCCL;;EAEf,IAAI,aAAa,OAAO,SAAS;;EAEjC,KAAK,cAAc,QAAQ;;EAE3B,OAAO,cAAc;;EAErB,OAAO,cAAc;;;;;;;;;;;wBAYP,qBAAqB,eAAe;;;;;;;;wBA0DpC,OAAO,OAAO;;;;;;;;;;;;;;;;;;;;;;cCzEjB;KACD,mBAAmB;;UAKd;EACf,MAAM;;;UAIS;WACN,MAAM;WACN,OAAO;;;KAoRN,YAAY,KAAK;;;;;;;;;;;qBClThB;;;;;qBASA;;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/model-settings.ts","../src/routes/model.ts","../src/file-store.ts","../src/server-log.ts","../src/headers.ts","../src/app.ts","../src/postgres-storage.ts","../src/postgres-rate-limits.ts","../src/db.ts","../src/file-collector.ts","../src/completers.ts"],"mappings":";;;;;;;;;;;;;;;qBASa;;KAGD,wBAAwB;KAExB;WACG;;WAEA;WACA,UAAU;WACV;WACA;;KAGV,cAAc,SAAS;;;;;;;;;;;;;;;wBA8BZ,cAAc,KAAK,cAAc;;;;UCrChC;WACN,UAAU;WACV;WACA,WAAW;;;;;;;;;;;;;;;;;;UCCL;;EAEf,IAAI,aAAa,OAAO,SAAS;;EAEjC,KAAK,cAAc,QAAQ;;EAE3B,OAAO,cAAc;;EAErB,OAAO,cAAc;;;;;;;;;;;wBAYP,qBAAqB,eAAe;;;;;;;;wBA0DpC,OAAO,OAAO;;;;;;;;;;;;;;;;;;;;;;cCzEjB;KACD,mBAAmB;;UAKd;EACf,MAAM;;;UAIS;WACN,MAAM;WACN,OAAO;;;KAoRN,YAAY,KAAK;;;;;;;;;;;qBClThB;;;;;qBASA;;;UCiCI;;EAEf;;;;;;EAMA;IAAmB;IAAe;;;;;;;;;;;EAUlC;;;;;;;;;;;;;;EAcA,iBAAiB;;;;;EAKjB;IAAwB;IAAa;;;;EAGrC,iBAAiB;;EAEjB;IAAmB;IAAa;;;;;;;;EAOhC;;;;;;EAMA;;;;;;;EAOA,YAAY;;;;;;;EAOZ;;EAEA,UAAU;;;;;;EAMV,QAAQ;;EAER;IAAmB;IAAa;;;;;;;EAMhC,MAAM;;;;;;;;;;;;;;;;;wBAkBc,UAAU,SAAS,SAAS,SAAS,aAAa,QAAQ;;;;wBC/IhE,sBAAsB,KAAK,SAAS,MAAM;;;UC2BzC;;WAEN;;WAEA;;;;;;;;WAQA,MAAM;;;UAIA;;WAEN,OAAO;;WAEP,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBA6BN,6BACd,qBACA,UAAS,2BACR;;;;;;;;wBC8GmB,gBAAgB,KAAK,SAAS,MAAM;;;;;;;;;;;;;;;;;;;;;;;;UC5KzC;EACf;;wBAGc,mBACd,SAAS,SACT,OAAO,WACP;EAAW;EAAqB;EAAqB,MAAM;IAC1D;;;;;;;;;wBCxBa,gBACd,UAAU,QAAQ;EAAiB;IACnC,SAAS,gBAAgB,MAAM,SAAS,OAAO,gBAAgB,QAAQ,YACtE"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { _ as SUBMISSION_TOKEN_HEADER, a as createLocalFileStore, c as createPostgresStorage, g as SCHEMA_HASH_HEADER, i as startFileCollector, l as bootstrapSchema, n as MODEL_PROVIDERS, o as sha256, r as modelSettings, s as createPostgresRateLimitStore, t as createCompleter, u as createApp } from "./completers-
|
|
1
|
+
import { _ as SUBMISSION_TOKEN_HEADER, a as createLocalFileStore, c as createPostgresStorage, g as SCHEMA_HASH_HEADER, i as startFileCollector, l as bootstrapSchema, n as MODEL_PROVIDERS, o as sha256, r as modelSettings, s as createPostgresRateLimitStore, t as createCompleter, u as createApp } from "./completers-CQQDS4qr.mjs";
|
|
2
2
|
export { MODEL_PROVIDERS, SCHEMA_HASH_HEADER, SUBMISSION_TOKEN_HEADER, bootstrapSchema, createApp, createCompleter, createLocalFileStore, createPostgresRateLimitStore, createPostgresStorage, modelSettings, sha256, startFileCollector };
|
package/dist/main.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as createLocalFileStore, c as createPostgresStorage, d as LOG_LEVELS, f as createServerLog, h as maxFileBytesFrom, i as startFileCollector, l as bootstrapSchema, m as workerLog, p as databaseNotices, r as modelSettings, s as createPostgresRateLimitStore, t as createCompleter, u as createApp } from "./completers-
|
|
1
|
+
import { a as createLocalFileStore, c as createPostgresStorage, d as LOG_LEVELS, f as createServerLog, h as maxFileBytesFrom, i as startFileCollector, l as bootstrapSchema, m as workerLog, p as databaseNotices, r as modelSettings, s as createPostgresRateLimitStore, t as createCompleter, u as createApp } from "./completers-CQQDS4qr.mjs";
|
|
2
2
|
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
3
3
|
import { deliveryHeaders, drainOutbox, isIpLiteral, isPrivateAddress } from "@formancy/server-core";
|
|
4
4
|
import postgres from "postgres";
|
|
@@ -7,12 +7,6 @@ import { Agent, fetch as fetch$1 } from "undici";
|
|
|
7
7
|
import { Readable } from "node:stream";
|
|
8
8
|
import { connect, isIP } from "node:net";
|
|
9
9
|
//#region src/webhook-url.ts
|
|
10
|
-
/**
|
|
11
|
-
* What is wrong with this webhook URL, or undefined when nothing is.
|
|
12
|
-
*
|
|
13
|
-
* Checked when the URL is saved, so an author finds out immediately rather
|
|
14
|
-
* than through a delivery that silently never works.
|
|
15
|
-
*/
|
|
16
10
|
function webhookUrlProblem(raw, options = {}) {
|
|
17
11
|
let url;
|
|
18
12
|
try {
|
|
@@ -112,7 +106,6 @@ async function deliver(input, options = {}) {
|
|
|
112
106
|
await agent.close();
|
|
113
107
|
}
|
|
114
108
|
}
|
|
115
|
-
/** Consume at most `cap` bytes so the connection can be reused, then stop. */
|
|
116
109
|
async function drain(response, cap) {
|
|
117
110
|
const reader = response.body?.getReader();
|
|
118
111
|
if (reader === void 0) return;
|
|
@@ -201,28 +194,7 @@ function startChallengeSweeper(storage, options = {}) {
|
|
|
201
194
|
}
|
|
202
195
|
//#endregion
|
|
203
196
|
//#region src/sigv4.ts
|
|
204
|
-
/**
|
|
205
|
-
* AWS Signature Version 4, for the four requests an object store needs.
|
|
206
|
-
*
|
|
207
|
-
* Ours rather than an SDK: sixty lines against a frozen specification, with the
|
|
208
|
-
* algorithm's own vocabulary in the names so it can be read beside AWS's description of
|
|
209
|
-
* it. It signs requests for **any** S3-compatible endpoint, so Garage, real S3 and
|
|
210
|
-
* Backblaze take the same code path
|
|
211
|
-
* ([0064](../../../docs/decisions/0064-an-object-store-behind-the-same-interface.md)).
|
|
212
|
-
*
|
|
213
|
-
* **What verifies it is a real S3 implementation in a container**, refusing a wrong
|
|
214
|
-
* signature with a 403. The unit tests pin the mechanical properties; they cannot tell
|
|
215
|
-
* whether the canonical request is *right*, only that it is consistent with itself.
|
|
216
|
-
*/
|
|
217
|
-
/** The SHA-256 of an empty body, which a GET or DELETE carries. */
|
|
218
197
|
const EMPTY_PAYLOAD_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
219
|
-
/**
|
|
220
|
-
* The headers to send, including `authorization`.
|
|
221
|
-
*
|
|
222
|
-
* Returns headers rather than mutating a request so that the caller's `fetch`
|
|
223
|
-
* options stay the caller's business, and so the signature is inspectable in a
|
|
224
|
-
* test without a server.
|
|
225
|
-
*/
|
|
226
198
|
function signRequest(args) {
|
|
227
199
|
const { method, url, payloadHash, service, region, accessKeyId, secretAccessKey, now } = args;
|
|
228
200
|
const headers = {};
|
|
@@ -253,44 +225,20 @@ function signRequest(args) {
|
|
|
253
225
|
headers["authorization"] = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${hmac(hmac(hmac(hmac(hmac(`AWS4${secretAccessKey}`, dateStamp), region), service), "aws4_request"), stringToSign).toString("hex")}`;
|
|
254
226
|
return headers;
|
|
255
227
|
}
|
|
256
|
-
/**
|
|
257
|
-
* `20150830T123600Z` — basic ISO 8601, not the extended form.
|
|
258
|
-
*
|
|
259
|
-
* `toISOString()` gives `2015-08-30T12:36:00.000Z`, which is rejected. Stripping
|
|
260
|
-
* the punctuation is the whole conversion, and the credential scope then wants
|
|
261
|
-
* the same instant as `20150830`, which is why this returns the long one and the
|
|
262
|
-
* caller slices it.
|
|
263
|
-
*/
|
|
264
228
|
function basicIso8601(at) {
|
|
265
229
|
return `${at.toISOString().replace(/[-:]/g, "").split(".")[0] ?? ""}Z`;
|
|
266
230
|
}
|
|
267
|
-
/**
|
|
268
|
-
* The path, encoded **once** for s3 and twice for everything else.
|
|
269
|
-
*
|
|
270
|
-
* This is the one place where s3 departs from the general rule, and getting it
|
|
271
|
-
* wrong produces a signature that is valid for every other AWS service and
|
|
272
|
-
* refused by the only one we call -- and refused only for keys containing a
|
|
273
|
-
* character that needs encoding, which is the worst possible way to find out.
|
|
274
|
-
*/
|
|
275
231
|
function canonicalPath(url, service) {
|
|
276
232
|
const once = url.pathname === "" ? "/" : url.pathname;
|
|
277
233
|
if (service === "s3") return once;
|
|
278
234
|
return once.split("/").map((segment) => uriEncode(decodeURIComponent(segment))).join("/");
|
|
279
235
|
}
|
|
280
|
-
/** Sorted by encoded name, values encoded, `=` even when empty. */
|
|
281
236
|
function canonicalQuery(url) {
|
|
282
237
|
const pairs = [];
|
|
283
238
|
for (const [name, value] of url.searchParams) pairs.push([uriEncode(name), uriEncode(value)]);
|
|
284
239
|
pairs.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
285
240
|
return pairs.map(([name, value]) => `${name}=${value}`).join("&");
|
|
286
241
|
}
|
|
287
|
-
/**
|
|
288
|
-
* AWS's `UriEncode`, which is not `encodeURIComponent`.
|
|
289
|
-
*
|
|
290
|
-
* The differences are small and each one is a 403: `!`, `'`, `(`, `)` and `*`
|
|
291
|
-
* are left alone by `encodeURIComponent` and must be encoded, hex digits must be
|
|
292
|
-
* uppercase, and a space is `%20` rather than `+`.
|
|
293
|
-
*/
|
|
294
242
|
function uriEncode(value) {
|
|
295
243
|
return encodeURIComponent(value).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
296
244
|
}
|
|
@@ -305,40 +253,11 @@ function hmac(key, data) {
|
|
|
305
253
|
function createS3FileStore(config) {
|
|
306
254
|
const endpoint = config.endpoint.replace(/\/+$/, "");
|
|
307
255
|
const timeoutMs = config.timeoutMs ?? 3e4;
|
|
308
|
-
/**
|
|
309
|
-
* The object's URL, with the key's slashes kept and everything else encoded.
|
|
310
|
-
*
|
|
311
|
-
* A key is a path inside the bucket, so its separators are structural. Every
|
|
312
|
-
* other character has to be encoded or the request line is invalid -- and a
|
|
313
|
-
* space encoded as `+`, which is what form encoding would do, names a
|
|
314
|
-
* different object.
|
|
315
|
-
*/
|
|
316
256
|
const urlFor = (key) => {
|
|
317
257
|
if (key === "" || key.includes("\\") || key.split("/").includes("..")) throw new Error(`Refusing a storage key that could address another bucket: ${key}`);
|
|
318
258
|
const path = key.split("/").map(encodeSegment).join("/");
|
|
319
259
|
return new URL(`${endpoint}/${encodeSegment(config.bucket)}/${path}`);
|
|
320
260
|
};
|
|
321
|
-
/**
|
|
322
|
-
* One request, and the clock that abandons it.
|
|
323
|
-
*
|
|
324
|
-
* **The clock measures how long the store keeps us waiting.** It starts when the
|
|
325
|
-
* request is sent and stops when the headers arrive -- which, for a PUT, is after
|
|
326
|
-
* the whole body has gone. It starts again at once, because the store now owes the
|
|
327
|
-
* body, stops when a chunk arrives, and starts again only when the caller asks for
|
|
328
|
-
* the next one -- so a chunk the caller is still holding costs the store nothing.
|
|
329
|
-
*
|
|
330
|
-
* **Not `AbortSignal.timeout`, which is what this replaced.** That bounds the whole
|
|
331
|
-
* exchange, body included, so a download still streaming when it ran out was cut
|
|
332
|
-
* off mid-file however steadily it was arriving. And how long a download streams
|
|
333
|
-
* is set by the person downloading it more than by the store: backpressure carries
|
|
334
|
-
* a slow connection all the way back to this socket, so a bound on the whole
|
|
335
|
-
* request was a bound on the downloader's bandwidth.
|
|
336
|
-
*
|
|
337
|
-
* The clock restarts at the headers rather than at the first read so that a body
|
|
338
|
-
* nobody reads -- the error document on a path that throws -- is still abandoned
|
|
339
|
-
* after `timeoutMs`, rather than holding its connection for however long something
|
|
340
|
-
* else takes to close it.
|
|
341
|
-
*/
|
|
342
261
|
const send = async (method, key, body) => {
|
|
343
262
|
const url = urlFor(key);
|
|
344
263
|
const payloadHash = body === void 0 ? EMPTY_PAYLOAD_SHA256 : createHash("sha256").update(body).digest("hex");
|
|
@@ -378,20 +297,6 @@ function createS3FileStore(config) {
|
|
|
378
297
|
body: timed(response.body, waiting)
|
|
379
298
|
};
|
|
380
299
|
};
|
|
381
|
-
/**
|
|
382
|
-
* A status that means "there is no such object", as opposed to one that means
|
|
383
|
-
* the store could not answer.
|
|
384
|
-
*
|
|
385
|
-
* The distinction is the whole reason this is a named function. Reading a
|
|
386
|
-
* could-not-answer as absence makes the collector delete the row for bytes that
|
|
387
|
-
* still exist and makes a download return nothing for a file the person can see
|
|
388
|
-
* listed.
|
|
389
|
-
*
|
|
390
|
-
* **404 only, and 403 deliberately not.** S3 answers 403 for an object a caller may
|
|
391
|
-
* not list, but this caller owns the bucket -- so 403 means the credentials are
|
|
392
|
-
* wrong, and reading that as absence would make a misconfigured deployment look like
|
|
393
|
-
* one where every file has vanished and have the collector delete every row.
|
|
394
|
-
*/
|
|
395
300
|
const isAbsent = (response) => response.status === 404;
|
|
396
301
|
const failed = (method, key, response) => /* @__PURE__ */ new Error(`S3 ${method} of ${key} failed: ${String(response.status)} ${response.statusText}`);
|
|
397
302
|
return {
|
|
@@ -427,16 +332,6 @@ function createS3FileStore(config) {
|
|
|
427
332
|
}
|
|
428
333
|
};
|
|
429
334
|
}
|
|
430
|
-
/**
|
|
431
|
-
* A clock for one request: started while the store owes something, stopped while it
|
|
432
|
-
* does not, and aborting the request if it ever runs out. `owed` names what the
|
|
433
|
-
* store failed to send, for the error that says so.
|
|
434
|
-
*
|
|
435
|
-
* Unref'd, as `AbortSignal.timeout` was: a deadline is not work. One is left running
|
|
436
|
-
* under every body nobody reads, and a referenced timer would hold the process it
|
|
437
|
-
* lives in for up to `timeoutMs` after everything else had finished. It still fires
|
|
438
|
-
* when it matters: a request still in flight holds the process up by its connection.
|
|
439
|
-
*/
|
|
440
335
|
function clock(timeoutMs, request) {
|
|
441
336
|
const controller = new AbortController();
|
|
442
337
|
let timer;
|
|
@@ -454,12 +349,6 @@ function clock(timeoutMs, request) {
|
|
|
454
349
|
stop
|
|
455
350
|
};
|
|
456
351
|
}
|
|
457
|
-
/**
|
|
458
|
-
* A body chunk by chunk, with the clock running only while the next chunk is awaited.
|
|
459
|
-
*
|
|
460
|
-
* An abort after the headers errors the body with the clock's reason, so a body the
|
|
461
|
-
* clock ran out on fails rather than ending early and looking complete.
|
|
462
|
-
*/
|
|
463
352
|
async function* timed(stream, waiting) {
|
|
464
353
|
try {
|
|
465
354
|
for await (const chunk of stream) {
|
|
@@ -471,36 +360,15 @@ async function* timed(stream, waiting) {
|
|
|
471
360
|
waiting.stop();
|
|
472
361
|
}
|
|
473
362
|
}
|
|
474
|
-
/** Reads a body to its end and drops it, which hands the connection back to the pool. */
|
|
475
363
|
async function discard(body) {
|
|
476
364
|
if (body === null) return;
|
|
477
365
|
for await (const _chunk of body);
|
|
478
366
|
}
|
|
479
|
-
/**
|
|
480
|
-
* AWS's `UriEncode` for one path segment.
|
|
481
|
-
*
|
|
482
|
-
* `encodeURIComponent` leaves `!'()*` alone and S3 wants them encoded, and its
|
|
483
|
-
* hex digits are already uppercase. Duplicated from the signer rather than
|
|
484
|
-
* shared, because the signer's copy is about hashing and this one is about the
|
|
485
|
-
* request line: if they ever need to differ, they differ, and a shared helper
|
|
486
|
-
* would make that a conversation instead of an edit.
|
|
487
|
-
*/
|
|
488
367
|
function encodeSegment(value) {
|
|
489
368
|
return encodeURIComponent(value).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
490
369
|
}
|
|
491
370
|
//#endregion
|
|
492
371
|
//#region src/file-store-settings.ts
|
|
493
|
-
/**
|
|
494
|
-
* Absent — or empty, see `setting` — means this deployment accepts no files,
|
|
495
|
-
* and that is a supported state rather than a misconfiguration: a form with a
|
|
496
|
-
* file field still renders and still submits, and the field says plainly that
|
|
497
|
-
* there is nowhere to put one. Turning uploads on is naming a directory, which
|
|
498
|
-
* in a container is naming a volume — and a volume is the one thing a
|
|
499
|
-
* self-hoster has to think about, so it is not defaulted here.
|
|
500
|
-
*
|
|
501
|
-
* Throws on a configuration that names two stores, or an object store without
|
|
502
|
-
* everything it needs, so the server refuses to start rather than serving one.
|
|
503
|
-
*/
|
|
504
372
|
function fileStoreSettings(env) {
|
|
505
373
|
const directory = setting(env, "FORMANCY_FILES_DIR");
|
|
506
374
|
const endpoint = setting(env, "FORMANCY_S3_ENDPOINT");
|
|
@@ -527,57 +395,14 @@ function fileStoreSettings(env) {
|
|
|
527
395
|
directory
|
|
528
396
|
};
|
|
529
397
|
}
|
|
530
|
-
/**
|
|
531
|
-
* A setting, with the empty string read as unset.
|
|
532
|
-
*
|
|
533
|
-
* Because compose cannot remove a variable, only blank it. Both compose files
|
|
534
|
-
* point FORMANCY_FILES_DIR at their volume, and `.env` switching to the object
|
|
535
|
-
* store does so by setting it to `""` — which, read as a directory, is two
|
|
536
|
-
* stores and a server that refuses to start. Read as a path it is no better:
|
|
537
|
-
* `createLocalFileStore('')` resolves to the working directory, which switches
|
|
538
|
-
* uploads on in a directory nobody chose — in the published image, `/app`,
|
|
539
|
-
* which its user cannot write.
|
|
540
|
-
*/
|
|
541
398
|
function setting(env, name) {
|
|
542
399
|
const value = env[name];
|
|
543
400
|
return value === "" ? void 0 : value;
|
|
544
401
|
}
|
|
545
402
|
//#endregion
|
|
546
403
|
//#region src/clamd-scanner.ts
|
|
547
|
-
/** How much is sent per INSTREAM chunk. clamd takes chunks of any size up to its stream limit. */
|
|
548
404
|
const CHUNK = 65536;
|
|
549
|
-
/**
|
|
550
|
-
* clamd's own default `StreamMaxLength`, 100 MiB — as the `clamd.conf` ClamAV 1.5.4 ships
|
|
551
|
-
* says, and as measured against it: a 90 MiB stream was answered, a 110 MiB one refused.
|
|
552
|
-
*/
|
|
553
405
|
const CLAMD_DEFAULT_MAX_BYTES = 104857600;
|
|
554
|
-
/**
|
|
555
|
-
* ClamAV's daemon, asked over its INSTREAM command
|
|
556
|
-
* ([0131](../../../docs/decisions/0131-an-upload-is-scanned-before-it-is-kept.md)).
|
|
557
|
-
*
|
|
558
|
-
* The protocol is clamd's own and small enough to speak directly — the command, the bytes in
|
|
559
|
-
* length-prefixed chunks, a zero-length chunk to end them, and one line back — so this adds
|
|
560
|
-
* no dependency to the server. Its tests run against a stand-in that speaks the protocol as
|
|
561
|
-
* clamd's manual describes it; it was run once against ClamAV 1.5.4 itself, and the
|
|
562
|
-
* documentation says what that showed and what it did not.
|
|
563
|
-
*
|
|
564
|
-
* **Only `stream: OK` is clean.** A finding is refused by its name. A file over clamd's size
|
|
565
|
-
* limit is refused too, because sending it again changes nothing. Anything else — an answer
|
|
566
|
-
* this does not understand, no answer, no daemon — rejects, which the use-case reads as a
|
|
567
|
-
* scanner that cannot be asked, and refuses the file for.
|
|
568
|
-
*
|
|
569
|
-
* **`maxBytes` is clamd's `StreamMaxLength`, and is checked before a byte is sent.** Past
|
|
570
|
-
* it, clamd says so and closes the connection while bytes may still be arriving. Against the
|
|
571
|
-
* stand-in the bytes in flight reset it and the answer was lost to `ECONNRESET`, which reads
|
|
572
|
-
* as a scanner that is down and would tell the person to try again forever; the real clamd
|
|
573
|
-
* measured here answered intact. Checked first, the outcome does not depend on which.
|
|
574
|
-
* Set it to what clamd is configured with.
|
|
575
|
-
*
|
|
576
|
-
* **`timeoutMs` is for silence, not for the whole scan.** It is the socket's idle timeout,
|
|
577
|
-
* restarted by any traffic on the connection, so it abandons a clamd that has stopped
|
|
578
|
-
* answering and not a scan that is still moving. Nothing here bounds how long a scan takes,
|
|
579
|
-
* which is why the lease a request holds a file under can be outlasted (0153).
|
|
580
|
-
*/
|
|
581
406
|
function createClamdScanner({ host, port, maxBytes = CLAMD_DEFAULT_MAX_BYTES, timeoutMs = 3e4 }) {
|
|
582
407
|
return { scan: (_file, bytes) => new Promise((resolve, reject) => {
|
|
583
408
|
if (bytes.byteLength > maxBytes) {
|
|
@@ -636,36 +461,6 @@ function verdictOf(reply) {
|
|
|
636
461
|
//#endregion
|
|
637
462
|
//#region src/trust-proxy.ts
|
|
638
463
|
const WHAT_TO_WRITE = "Write the address of the reverse proxy in front of this server, e.g. 10.0.0.2, or a comma-separated list of addresses and CIDR ranges, e.g. 10.0.0.2,172.18.0.0/16 — or leave it unset to trust no proxy.";
|
|
639
|
-
/**
|
|
640
|
-
* The proxies whose `X-Forwarded-For` this server believes, read from
|
|
641
|
-
* `FORMANCY_TRUST_PROXY`.
|
|
642
|
-
*
|
|
643
|
-
* Every limit on the public plane counts the client's address. Behind a reverse
|
|
644
|
-
* proxy the socket's address is the proxy's, so trusting nothing gives every
|
|
645
|
-
* respondent one shared budget; trusting the wrong thing lets a client write its
|
|
646
|
-
* own `X-Forwarded-For` and choose a fresh budget per request. So the setting
|
|
647
|
-
* names the proxies, and only addresses and ranges:
|
|
648
|
-
*
|
|
649
|
-
* - **No hop count.** Fastify's option takes a number, and the Fastify this
|
|
650
|
-
* server depends on treats one as "trust nothing", because a count cannot
|
|
651
|
-
* tell the proxy from a client that connects directly. Accepting it here
|
|
652
|
-
* would be a setting that reads as configured and does nothing;
|
|
653
|
-
* `trust-proxy.test.ts` checks that Fastify still ignores one.
|
|
654
|
-
* - **No `true`,** which Fastify also takes, and which believes anybody.
|
|
655
|
-
* - **No names,** such as `uniquelocal`, which Fastify's `proxy-addr` expands
|
|
656
|
-
* into ranges nobody reading the setting can see — that one is every private
|
|
657
|
-
* address, a Docker network's gateway with it, and the gateway is where
|
|
658
|
-
* Docker hands published-port connections over from. Refused by the address
|
|
659
|
-
* check below, as a hostname is.
|
|
660
|
-
*
|
|
661
|
-
* Decision 0156 has the reasoning, and what was measured against Docker.
|
|
662
|
-
*
|
|
663
|
-
* Absent or empty returns undefined, which trusts nothing — the behaviour before
|
|
664
|
-
* this setting existed, and the right one with no proxy in front. Anything else
|
|
665
|
-
* that is not an address or a range is refused here, with a sentence saying what
|
|
666
|
-
* to write, rather than left to Fastify's parser, whose TypeError names neither
|
|
667
|
-
* the variable nor the fix.
|
|
668
|
-
*/
|
|
669
464
|
function trustProxyFrom(value) {
|
|
670
465
|
if (value === void 0 || value.trim() === "") return void 0;
|
|
671
466
|
const trimmed = value.trim();
|
|
@@ -675,14 +470,6 @@ function trustProxyFrom(value) {
|
|
|
675
470
|
for (const entry of entries) if (!isAddressOrRange(entry)) throw new Error(`FORMANCY_TRUST_PROXY has "${entry}", which is not an IP address or a CIDR range. ${WHAT_TO_WRITE}`);
|
|
676
471
|
return entries;
|
|
677
472
|
}
|
|
678
|
-
/**
|
|
679
|
-
* An address, or an address with a prefix length its family allows.
|
|
680
|
-
*
|
|
681
|
-
* A zero prefix is refused, as Fastify's parser refuses it: `/0` is every
|
|
682
|
-
* address, which is `true` spelled differently. A netmask after the slash is
|
|
683
|
-
* refused too, though Fastify reads one — a second spelling of the same thing
|
|
684
|
-
* is a second thing to get wrong, and a prefix length says it.
|
|
685
|
-
*/
|
|
686
473
|
function isAddressOrRange(entry) {
|
|
687
474
|
const slash = entry.indexOf("/");
|
|
688
475
|
if (slash === -1) return isIP(entry) !== 0;
|
|
@@ -694,18 +481,6 @@ function isAddressOrRange(entry) {
|
|
|
694
481
|
}
|
|
695
482
|
//#endregion
|
|
696
483
|
//#region src/log-settings.ts
|
|
697
|
-
/**
|
|
698
|
-
* How much the server logs, read from `FORMANCY_LOG_LEVEL`
|
|
699
|
-
* ([0168](../../../docs/decisions/0168-the-log-is-built-from-a-list-of-fields.md)).
|
|
700
|
-
*
|
|
701
|
-
* Unset or empty is `info`: a line per request and per error. The log is on unless somebody
|
|
702
|
-
* turns it off, because off is the state this setting replaced, where nothing said why a
|
|
703
|
-
* request failed. `off` keeps no log at all; anything else that is not one of pino's level
|
|
704
|
-
* names, spelled as pino spells them, stops the server at startup — a server that started
|
|
705
|
-
* anyway would log at a level nobody chose, or not at all while its operator believed it
|
|
706
|
-
* did. Its own module because `main.ts` connects to a database when it is imported, so
|
|
707
|
-
* nothing in it can be tested.
|
|
708
|
-
*/
|
|
709
484
|
function logLevelFrom(env) {
|
|
710
485
|
const value = env["FORMANCY_LOG_LEVEL"]?.trim() ?? "";
|
|
711
486
|
if (value === "") return "info";
|
|
@@ -717,28 +492,12 @@ function logLevelFrom(env) {
|
|
|
717
492
|
//#endregion
|
|
718
493
|
//#region src/main.ts
|
|
719
494
|
process.env["RECHECK_BACKEND"] ??= "pure";
|
|
720
|
-
/**
|
|
721
|
-
* The runnable server: `node dist/main.mjs` with DATABASE_URL set, or
|
|
722
|
-
* `docker compose up` from the repo root, which supplies both.
|
|
723
|
-
*
|
|
724
|
-
* Bootstraps its own tables on start — a self-hosted skeleton that works five
|
|
725
|
-
* minutes after `git clone` is the adoption funnel; migrations arrive with the
|
|
726
|
-
* lifecycle work.
|
|
727
|
-
*/
|
|
728
495
|
const databaseUrl = process.env["DATABASE_URL"];
|
|
729
496
|
if (databaseUrl === void 0 || databaseUrl === "") {
|
|
730
497
|
console.error("DATABASE_URL is required, e.g. postgres://formancy:formancy@localhost:5432/formancy");
|
|
731
498
|
process.exit(1);
|
|
732
499
|
}
|
|
733
500
|
const port = Number(process.env["PORT"] ?? 4380);
|
|
734
|
-
/**
|
|
735
|
-
* The server's log, on standard output: a JSON line per request and per error, each built
|
|
736
|
-
* from a list of fields, so no body, header, query string, answer or credential has anywhere
|
|
737
|
-
* to go (0168). `info` unless `FORMANCY_LOG_LEVEL` says otherwise; `off` keeps none, and a
|
|
738
|
-
* level the server does not have stops it here. Read first, so the database's notices on the
|
|
739
|
-
* first connection go through it too rather than being printed whole by the driver, and so
|
|
740
|
-
* does the rate limits' counter, which is made before the app whose log it would otherwise use.
|
|
741
|
-
*/
|
|
742
501
|
const logLevel = logLevelFrom(process.env);
|
|
743
502
|
const log = logLevel === "off" ? void 0 : createServerLog(process.stdout, logLevel);
|
|
744
503
|
const sql = postgres(databaseUrl, { onnotice: databaseNotices(log) });
|
|
@@ -749,20 +508,9 @@ if (authSecret === void 0 || authSecret === "") {
|
|
|
749
508
|
}
|
|
750
509
|
const adminEmail = process.env["FORMANCY_ADMIN_EMAIL"];
|
|
751
510
|
const adminPassword = process.env["FORMANCY_ADMIN_PASSWORD"];
|
|
752
|
-
/** Where uploaded bytes go: the rules are in `fileStoreSettings`, which throws. */
|
|
753
511
|
const storeSettings = fileStoreSettings(process.env);
|
|
754
512
|
const fileStore = storeSettings.kind === "s3" ? createS3FileStore(storeSettings.config) : storeSettings.kind === "local" ? createLocalFileStore(storeSettings.directory) : void 0;
|
|
755
513
|
const maxFileBytes = maxFileBytesFrom(process.env);
|
|
756
|
-
/**
|
|
757
|
-
* A ClamAV daemon to ask about every upload before its bytes are kept.
|
|
758
|
-
*
|
|
759
|
-
* Unset is a supported state: no file is scanned, which the documentation says. Set, a
|
|
760
|
-
* file is kept only once clamd has called it clean, and refused when clamd cannot be
|
|
761
|
-
* reached — the point of configuring a scanner is that nothing unscanned gets in
|
|
762
|
-
* ([0131](../../../docs/decisions/0131-an-upload-is-scanned-before-it-is-kept.md)).
|
|
763
|
-
* Not checked at startup: clamd takes minutes to load its signatures, and a server that
|
|
764
|
-
* refused to start until then would turn a slow scanner into an outage of everything.
|
|
765
|
-
*/
|
|
766
514
|
const clamdHost = process.env["FORMANCY_CLAMD_HOST"];
|
|
767
515
|
const clamdPort = Number(process.env["FORMANCY_CLAMD_PORT"] ?? 3310);
|
|
768
516
|
const clamdMaxBytes = Number(process.env["FORMANCY_CLAMD_MAX_BYTES"] ?? 104857600);
|
|
@@ -773,37 +521,9 @@ const scanner = clamdHost === void 0 || clamdHost === "" ? void 0 : createClamdS
|
|
|
773
521
|
port: clamdPort,
|
|
774
522
|
maxBytes: clamdMaxBytes
|
|
775
523
|
});
|
|
776
|
-
/**
|
|
777
|
-
* Turns the proof-of-work challenge on for anonymous submissions.
|
|
778
|
-
*
|
|
779
|
-
* Unset is a supported state rather than a misconfiguration: a deployment
|
|
780
|
-
* whose forms all require a session has no anonymous surface to protect, and
|
|
781
|
-
* the challenge route says so with a 404 rather than failing. A deployment
|
|
782
|
-
* with public forms should set it — the other layers are rate limits, an
|
|
783
|
-
* origin allowlist and a body cap, all of which an attacker with a few
|
|
784
|
-
* addresses walks past.
|
|
785
|
-
*
|
|
786
|
-
* Separate from FORMANCY_AUTH_SECRET so that rotating one does not invalidate
|
|
787
|
-
* the other: rotating this one costs an unsolved puzzle, rotating that one
|
|
788
|
-
* costs everybody their session.
|
|
789
|
-
*/
|
|
790
524
|
const challengeSecret = process.env["FORMANCY_CHALLENGE_SECRET"];
|
|
791
525
|
if (challengeSecret !== void 0 && challengeSecret.length < 32) throw new Error("FORMANCY_CHALLENGE_SECRET must be at least 32 characters, or unset.");
|
|
792
|
-
/**
|
|
793
|
-
* The reverse proxies whose X-Forwarded-For names the client. Unset trusts none,
|
|
794
|
-
* which is right with nothing in front and wrong behind a proxy: every
|
|
795
|
-
* respondent would share its one rate-limit budget. Refused at startup when it
|
|
796
|
-
* is not addresses and ranges, since a server that started anyway would count
|
|
797
|
-
* the wrong client and nothing would say so.
|
|
798
|
-
*/
|
|
799
526
|
const trustProxy = trustProxyFrom(process.env["FORMANCY_TRUST_PROXY"]);
|
|
800
|
-
/**
|
|
801
|
-
* The model the builders ask through this server, with its key kept here rather than in a
|
|
802
|
-
* browser (0165). Unset is a supported state and the default: no model, no prompt pane in
|
|
803
|
-
* the admin, and no form sent anywhere. Half a configuration — a key with no provider, a
|
|
804
|
-
* provider with no model — is refused here, because a server that started anyway would
|
|
805
|
-
* have no model while the operator believed it had one. The key is never logged.
|
|
806
|
-
*/
|
|
807
527
|
const modelChoice = modelSettings(process.env);
|
|
808
528
|
const model = modelChoice.kind === "none" ? void 0 : {
|
|
809
529
|
provider: modelChoice.provider,
|