@fabrika/app 0.5.3 → 0.5.5
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 +9 -9
- package/skill/SKILL.md +11 -10
- package/skill/references/app-setup.md +8 -8
- package/skill/references/files.md +23 -0
- package/skill/references/public-intake.md +98 -16
- package/skill/references/spa.md +5 -3
- package/src/contract/artifact-schema.ts +2 -1
- package/src/contract/build.ts +109 -16
- package/src/contract/context.ts +14 -5
- package/src/contract/declaration.ts +8 -3
- package/src/contract/defaults.ts +27 -0
- package/src/contract/define.ts +15 -1
- package/src/contract/kit.ts +8 -2
- package/src/files/collections.ts +195 -16
- package/src/files/handle.ts +21 -2
- package/src/implement/anonymous.ts +15 -2
- package/src/implement/boot.ts +31 -6
- package/src/implement/dispatch.ts +194 -5
- package/src/implement/implement.ts +6 -2
- package/src/implement/invocation.ts +1 -0
- package/src/implement/multipart.ts +332 -0
- package/src/jobs/runner.ts +38 -15
- package/src/react/testing.ts +3 -0
- package/src/testing/endpoint-access.ts +12 -6
- package/src/testing/kit.ts +16 -3
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type EndpointPlan,
|
|
14
14
|
type EndpointRow,
|
|
15
15
|
type ExportDescription,
|
|
16
|
+
type FormFileArtifact,
|
|
16
17
|
isAttachmentShape,
|
|
17
18
|
type LinkTokenClaims,
|
|
18
19
|
parseEndpointHeader,
|
|
@@ -32,7 +33,15 @@ import type { DataKind } from '@fabrika/db/tables'
|
|
|
32
33
|
import type { TokenVerifier } from '@fabrika/jwks'
|
|
33
34
|
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
34
35
|
import { z } from 'zod/v4'
|
|
35
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
BROWSER_CONFIG_FILE,
|
|
38
|
+
FORM_FILE_LIMITS,
|
|
39
|
+
FORM_INPUT_PART,
|
|
40
|
+
FORM_MULTIPART_TYPE,
|
|
41
|
+
joinPath,
|
|
42
|
+
PAGE_CALLER_FILE,
|
|
43
|
+
WEBHOOK_BINDING_PARAMETER,
|
|
44
|
+
} from '../contract/defaults'
|
|
36
45
|
import type { Schema } from '../contract/define'
|
|
37
46
|
import {
|
|
38
47
|
BadRequestError,
|
|
@@ -45,18 +54,18 @@ import {
|
|
|
45
54
|
UnauthorizedError,
|
|
46
55
|
} from '../errors'
|
|
47
56
|
import type { RequestExecutionContext } from '../fetch'
|
|
48
|
-
import type { FileCollections } from '../files/collections'
|
|
57
|
+
import type { FileCollections, ReceivedFormFile } from '../files/collections'
|
|
49
58
|
import { FileDownload, fileResponse, withFileTreatment } from '../files/treatment'
|
|
50
|
-
import { bootstrapPath, bootstrapResponse, githubAccounts, readBoundedBody, verifyGithubSignature, withBootstrap } from './anonymous'
|
|
59
|
+
import { abortedBody, bootstrapPath, bootstrapResponse, githubAccounts, readBoundedBody, verifyGithubSignature, withBootstrap } from './anonymous'
|
|
51
60
|
import { attachmentResponse } from './attachment'
|
|
52
|
-
import { handlerSignal } from './bounded'
|
|
61
|
+
import { handlerSignal, readBoundedBytes } from './bounded'
|
|
53
62
|
import type { BrowserConfig } from './browser-config'
|
|
54
63
|
import { CapabilityLimitError, type CapabilityMint, RequestFrame, runInRequest } from './capability'
|
|
55
64
|
import { CHALLENGE_LIMITS, type ChallengeVerifier } from './challenge'
|
|
56
65
|
import { connectContext, type ImportWire } from './connect'
|
|
57
66
|
import type { FailureReports } from './failures'
|
|
58
67
|
import { ServerFault } from './faults'
|
|
59
|
-
import { ACCEPTED_BUCKET, clientBucket, type SubmissionFields, submissionOf } from './form-admission'
|
|
68
|
+
import { ACCEPTED_BUCKET, clientBucket, type Submission, type SubmissionFields, submissionOf } from './form-admission'
|
|
60
69
|
import { type HeaderPolicy, withHeaderPolicy } from './headers'
|
|
61
70
|
import type { ContractDatabase } from './implement'
|
|
62
71
|
import { endpointData, Invocation, noFileCollections, siteData, type SiteDeclaration, undeclaredSql } from './invocation'
|
|
@@ -70,6 +79,7 @@ import {
|
|
|
70
79
|
type MembershipRequest,
|
|
71
80
|
type ReadDirectory,
|
|
72
81
|
} from './membership'
|
|
82
|
+
import { multipartBoundary, MultipartError, multipartParts, type MultipartReader } from './multipart'
|
|
73
83
|
import { pageCallerOf, presentedBy, type Principal } from './principal'
|
|
74
84
|
import { project, ProjectionRefusedError } from './project'
|
|
75
85
|
import { deliveryKey, WEBHOOK_REPLAY_RETENTION_MS, type WebhookPrincipal, webhookSubject } from './webhook'
|
|
@@ -167,6 +177,16 @@ export type ServedEndpoint =
|
|
|
167
177
|
readonly site?: SiteDeclaration
|
|
168
178
|
}
|
|
169
179
|
|
|
180
|
+
/** A form's file fields and the collections they land in: such a form reads `multipart/form-data`. */
|
|
181
|
+
export interface FormFileAdmission {
|
|
182
|
+
readonly fields: ReadonlyMap<string, FormFileArtifact>
|
|
183
|
+
readonly collections: FileCollections
|
|
184
|
+
/** The bytes all the form's files may write per hour; each file reserves its collection's `maxBytes` first. */
|
|
185
|
+
readonly bytesPerHour: number
|
|
186
|
+
/** How long the whole body may take to arrive. */
|
|
187
|
+
readonly timeoutMs: number
|
|
188
|
+
}
|
|
189
|
+
|
|
170
190
|
/** What a form admits, decided once at boot from the approved artifact and the application's variables. */
|
|
171
191
|
export interface FormAdmission {
|
|
172
192
|
/** Requests per minute from one client, admitted before the body is read. */
|
|
@@ -174,6 +194,8 @@ export interface FormAdmission {
|
|
|
174
194
|
/** Submissions per minute the form accepts in all: only one that passed its honeypot and its challenge counts. */
|
|
175
195
|
readonly perForm: number
|
|
176
196
|
readonly fields: SubmissionFields
|
|
197
|
+
/** The form's file fields; `null` for a form that takes none, whose body is JSON. */
|
|
198
|
+
readonly files: FormFileAdmission | null
|
|
177
199
|
/** The challenge's verifier, with the secret the application variable holds; `null` without a challenge. */
|
|
178
200
|
readonly challenge: ChallengeVerifier | null
|
|
179
201
|
/** The one token the challenge does not record, so it passes again: `fabrika app dev`'s test widget's; `null` otherwise. */
|
|
@@ -740,6 +762,10 @@ export class ContractRuntime {
|
|
|
740
762
|
if (!(await admissions.take(served.form, clientBucket(request), admission.perClient))) {
|
|
741
763
|
throw formRateLimited(served.row)
|
|
742
764
|
}
|
|
765
|
+
// A form with files whose every file is optional also takes the JSON body of a page that sends none.
|
|
766
|
+
if (admission.files !== null && mediaType(request.headers.get('Content-Type')) === FORM_MULTIPART_TYPE) {
|
|
767
|
+
return await this.#multipartForm(served, admission.files, request, exec)
|
|
768
|
+
}
|
|
743
769
|
const body = await readBoundedBody(request, maxBytesOf(served.row))
|
|
744
770
|
const submission = submissionOf(strictInput(served.row, served.input, parseJson(body)), admission.fields)
|
|
745
771
|
if (submission.trapped) {
|
|
@@ -760,6 +786,142 @@ export class ContractRuntime {
|
|
|
760
786
|
return jsonResponse({ result: { received: true } }, 200)
|
|
761
787
|
}
|
|
762
788
|
|
|
789
|
+
/**
|
|
790
|
+
* A form that takes files reads its `multipart/form-data` body as it streams, in the order of `#form`: its first part
|
|
791
|
+
* is the input as JSON (within the form's `maxBytes`), so the input, the honeypot and the challenge are decided before
|
|
792
|
+
* a byte of any file is read. Each file part then lands in its collection under a pending row of its own, owned by
|
|
793
|
+
* nobody, and the inbox row that names each file's id makes them held in its own transaction. A submission that does
|
|
794
|
+
* not commit deletes every file it received. The whole body arrives within the form's `timeoutMs`.
|
|
795
|
+
*/
|
|
796
|
+
async #multipartForm(
|
|
797
|
+
served: Extract<ServedEndpoint, { kind: 'form' }>,
|
|
798
|
+
files: FormFileAdmission,
|
|
799
|
+
request: Request,
|
|
800
|
+
exec: RequestExecutionContext | undefined,
|
|
801
|
+
): Promise<Response> {
|
|
802
|
+
const { admission } = served
|
|
803
|
+
const boundary = multipartBoundary(request.headers.get('Content-Type'))
|
|
804
|
+
if (boundary === null) {
|
|
805
|
+
throw new HttpError(400, 'admission', `'${served.row.id}' takes multipart/form-data with a boundary`)
|
|
806
|
+
}
|
|
807
|
+
// Each part is bounded on its own as it streams; a declared length past all of them together is refused unread.
|
|
808
|
+
const declaredFiles = [...files.fields.values()]
|
|
809
|
+
const limit = maxBytesOf(served.row) + declaredFiles.reduce((sum, field) => sum + files.collections.maxBytesOf(field.collection), 0)
|
|
810
|
+
+ (declaredFiles.length + 1) * MULTIPART_PART_OVERHEAD
|
|
811
|
+
const declared = Number(request.headers.get('Content-Length') ?? '')
|
|
812
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
813
|
+
throw new HttpError(413, 'too_large', `The body is larger than the ${limit} bytes the form's input and files can hold`)
|
|
814
|
+
}
|
|
815
|
+
const deadline = AbortSignal.any([request.signal, AbortSignal.timeout(files.timeoutMs)])
|
|
816
|
+
const parts = multipartParts(request.body ?? new Blob([]).stream(), boundary, { maxHeaderBytes: FORM_FILE_LIMITS.maxPartHeaderBytes })
|
|
817
|
+
const stop = () => {
|
|
818
|
+
parts.cancel().catch(() => {})
|
|
819
|
+
}
|
|
820
|
+
deadline.addEventListener('abort', stop, { once: true })
|
|
821
|
+
const received: ReceivedFormFile[] = []
|
|
822
|
+
/**
|
|
823
|
+
* `kept` once the inbox row may have committed: an append that threw may have committed before its answer was
|
|
824
|
+
* lost, so its files are left to the sweep, which deletes a pending row's object after an hour and never a held one.
|
|
825
|
+
*/
|
|
826
|
+
let kept = false
|
|
827
|
+
try {
|
|
828
|
+
const submission = await this.#multipartInput(served, parts)
|
|
829
|
+
if (submission.trapped) {
|
|
830
|
+
return jsonResponse({ result: { received: true } }, 200)
|
|
831
|
+
}
|
|
832
|
+
if (admission.challenge === null) {
|
|
833
|
+
// `boot` refuses a form with files and no challenge; reaching this is a composition bug.
|
|
834
|
+
throw new ServerFault('contract_inconsistent', `'${served.row.id}' takes files without a challenge`)
|
|
835
|
+
}
|
|
836
|
+
await this.#challenge(served.form, admission.challenge, submission.token, request, admission.repeatableToken)
|
|
837
|
+
const columns = new Map<string, string | null>()
|
|
838
|
+
for (let part = await parts.next(); part !== null; part = await parts.next()) {
|
|
839
|
+
const field = files.fields.get(part.name)
|
|
840
|
+
if (field === undefined || columns.has(part.name) || part.filename === null) {
|
|
841
|
+
await part.body.cancel()
|
|
842
|
+
throw new HttpError(
|
|
843
|
+
400,
|
|
844
|
+
'multipart',
|
|
845
|
+
`'${served.row.id}': a part is no file field of the form, a field arrives twice, or a file has no filename`,
|
|
846
|
+
)
|
|
847
|
+
}
|
|
848
|
+
const file = await this.#receiveFormFile(served, files, field.collection, part)
|
|
849
|
+
if (file !== null) {
|
|
850
|
+
received.push(file)
|
|
851
|
+
}
|
|
852
|
+
columns.set(part.name, file?.id ?? null)
|
|
853
|
+
}
|
|
854
|
+
const row: Record<string, unknown> = { ...submission.row }
|
|
855
|
+
for (const [name, field] of files.fields) {
|
|
856
|
+
const id = columns.get(name) ?? null
|
|
857
|
+
if (id === null && field.required) {
|
|
858
|
+
throw new HttpError(400, 'validation', `${name}: the form needs this file`, {
|
|
859
|
+
issues: [{ path: name, code: 'required', message: 'a file is required' }],
|
|
860
|
+
})
|
|
861
|
+
}
|
|
862
|
+
row[name] = id
|
|
863
|
+
}
|
|
864
|
+
const held = { files: received.map(({ id, size, sha256 }) => ({ id, size, sha256 })), holdMs: FORM_FILE_LIMITS.holdMs }
|
|
865
|
+
kept = true
|
|
866
|
+
const appended = await this.#framework().appendToInbox(served.form, row, { bucket: ACCEPTED_BUCKET, limit: admission.perForm }, held)
|
|
867
|
+
if (!appended) {
|
|
868
|
+
kept = false
|
|
869
|
+
throw formRateLimited(served.row)
|
|
870
|
+
}
|
|
871
|
+
} catch (error) {
|
|
872
|
+
throw multipartFailure(error, parts, deadline, request)
|
|
873
|
+
} finally {
|
|
874
|
+
deadline.removeEventListener('abort', stop)
|
|
875
|
+
await parts.cancel()
|
|
876
|
+
if (!kept) {
|
|
877
|
+
await files.collections.releaseFormFiles(received)
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
if (served.then !== null) {
|
|
881
|
+
// Without an execution context the kick runs detached; it never rejects.
|
|
882
|
+
const kicked = this.#parts.kick(served.then)
|
|
883
|
+
exec?.waitUntil(kicked)
|
|
884
|
+
}
|
|
885
|
+
return jsonResponse({ result: { received: true } }, 200)
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* One file of a submission, within the form's hourly byte budget: it reserves its collection's `maxBytes` before its
|
|
890
|
+
* first byte is written (429 when they no longer fit) and gives back what it did not use. A file that failed keeps
|
|
891
|
+
* its reservation: its bytes may have been written.
|
|
892
|
+
*/
|
|
893
|
+
async #receiveFormFile(
|
|
894
|
+
served: Extract<ServedEndpoint, { kind: 'form' }>,
|
|
895
|
+
files: FormFileAdmission,
|
|
896
|
+
collection: string,
|
|
897
|
+
part: { readonly filename: string | null; readonly contentType: string | null; readonly body: ReadableStream<Uint8Array> },
|
|
898
|
+
): Promise<ReceivedFormFile | null> {
|
|
899
|
+
const admissions = this.#framework().forms
|
|
900
|
+
const reserved = files.collections.maxBytesOf(collection)
|
|
901
|
+
if (!(await admissions.reserveBytes(served.form, reserved, files.bytesPerHour))) {
|
|
902
|
+
await part.body.cancel()
|
|
903
|
+
throw new HttpError(429, 'rate_limited', `'${served.row.id}' takes no more files this hour; try again later`)
|
|
904
|
+
}
|
|
905
|
+
const file = await files.collections.receiveFormFile(served.form, collection, part)
|
|
906
|
+
await admissions.releaseBytes(served.form, reserved - (file?.size ?? 0))
|
|
907
|
+
return file
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** The first part of a multipart form: its input, as JSON within the form's `maxBytes`, parsed strictly. */
|
|
911
|
+
async #multipartInput(served: Extract<ServedEndpoint, { kind: 'form' }>, parts: MultipartReader): Promise<Submission> {
|
|
912
|
+
const first = await parts.next()
|
|
913
|
+
if (first === null || first.name !== FORM_INPUT_PART || first.filename !== null) {
|
|
914
|
+
await first?.body.cancel()
|
|
915
|
+
throw new HttpError(400, 'multipart', `'${served.row.id}': the first part is the input as JSON, named '${FORM_INPUT_PART}', without a filename`)
|
|
916
|
+
}
|
|
917
|
+
const limit = maxBytesOf(served.row)
|
|
918
|
+
const bytes = await readBoundedBytes(first.body, { maxBytes: limit })
|
|
919
|
+
if (bytes === null) {
|
|
920
|
+
throw new HttpError(413, 'too_large', `The input is larger than ${limit} bytes`)
|
|
921
|
+
}
|
|
922
|
+
return submissionOf(strictInput(served.row, served.input, parseJson(bytes)), served.admission.fields)
|
|
923
|
+
}
|
|
924
|
+
|
|
763
925
|
/**
|
|
764
926
|
* The token verifies for this host and this form, and was not accepted before unless it is the `repeatable` one: a
|
|
765
927
|
* failure is 400, an unreachable provider 503.
|
|
@@ -939,6 +1101,33 @@ function admit(row: CredentialedEndpoint, request: Request): void {
|
|
|
939
1101
|
admitContentType(row, request)
|
|
940
1102
|
}
|
|
941
1103
|
|
|
1104
|
+
/** What one part adds to a multipart body beside its bytes: its delimiter and its headers, at most. */
|
|
1105
|
+
const MULTIPART_PART_OVERHEAD = FORM_FILE_LIMITS.maxPartHeaderBytes + 128
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* What a failed multipart submission answers: a body that broke the format 400, one that did not arrive in time 408,
|
|
1109
|
+
* and otherwise the failure itself, whichever step met the broken stream first.
|
|
1110
|
+
*/
|
|
1111
|
+
function multipartFailure(error: unknown, parts: MultipartReader, deadline: AbortSignal, request: Request): unknown {
|
|
1112
|
+
if (isStructuralError(error) && error.httpStatus < 500) {
|
|
1113
|
+
return error
|
|
1114
|
+
}
|
|
1115
|
+
if (request.signal.aborted) {
|
|
1116
|
+
return abortedBody()
|
|
1117
|
+
}
|
|
1118
|
+
if (deadline.aborted) {
|
|
1119
|
+
return new HttpError(408, 'timeout', 'The submission did not arrive in time')
|
|
1120
|
+
}
|
|
1121
|
+
const broken = error instanceof MultipartError ? error : parts.failure()
|
|
1122
|
+
if (broken?.reason === 'interrupted') {
|
|
1123
|
+
return abortedBody()
|
|
1124
|
+
}
|
|
1125
|
+
if (broken !== null) {
|
|
1126
|
+
return new HttpError(400, 'multipart', `The body is not the multipart/form-data the form reads: ${broken.message}`)
|
|
1127
|
+
}
|
|
1128
|
+
return error
|
|
1129
|
+
}
|
|
1130
|
+
|
|
942
1131
|
/** The content types an endpoint admits; an anonymous endpoint has no session, so this is its whole admission. */
|
|
943
1132
|
function formRateLimited(row: EndpointRow): HttpError {
|
|
944
1133
|
return new HttpError(429, 'rate_limited', `'${row.id}' takes no more submissions right now; try again in a minute`)
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { TablePolicyArtifact } from '@fabrika/auth-core'
|
|
5
5
|
import type { DataLayer } from '@fabrika/db'
|
|
6
6
|
import type { CapabilityAuthority } from '@fabrika/db/capability'
|
|
7
|
+
import type { Session } from '@fabrika/db/framework'
|
|
7
8
|
import type { EmailSender } from '@fabrika/email'
|
|
8
9
|
import type { ConnectSnapshot } from '../contract/connect'
|
|
9
10
|
import type { AnyContract } from '../contract/define'
|
|
@@ -16,8 +17,11 @@ import type { ConnectSnapshots, ExactHandlers, HandlersOf } from './types'
|
|
|
16
17
|
*/
|
|
17
18
|
export interface ContractDatabase {
|
|
18
19
|
readonly tables: Readonly<Record<string, TablePolicyArtifact>>
|
|
19
|
-
/**
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* The containers an object lies in, by dimension, or `null` when the table that owns `dimension` has no such row.
|
|
22
|
+
* `using` is a job's transaction, which also sees the object it wrote; a layer of the application's own ignores it.
|
|
23
|
+
*/
|
|
24
|
+
locate(dimension: string, id: string, using?: Session): Promise<ReadonlyMap<string, string> | null>
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
/** `scoped(tables)` of `@fabrika/db`, which `boot` binds, or a `ContractDatabase` of the application's own. */
|
|
@@ -267,6 +267,7 @@ export class Invocation {
|
|
|
267
267
|
check: boundTo(this.#frame, () => transaction.ended, 'ctx.files'),
|
|
268
268
|
session: () => transaction.session(),
|
|
269
269
|
readOnly: kind === 'mutation' && !transaction.readOnly ? null : 'a query reads only; remove files in a mutation',
|
|
270
|
+
attaches: false,
|
|
270
271
|
}
|
|
271
272
|
}
|
|
272
273
|
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// A `multipart/form-data` body read part by part as it streams (RFC 7578): what a form that takes files receives. The
|
|
2
|
+
// reader never buffers a part's body: each part's bytes are a stream of their own, which the caller consumes to the
|
|
3
|
+
// end before it asks for the next part, so a form reads its input and checks its challenge before a file's first byte
|
|
4
|
+
// is read. It is strict where browsers are: no preamble, a CRLF or `--` after each delimiter, headers within a bound.
|
|
5
|
+
// Anything else is a `MultipartError`, which the form answers 400.
|
|
6
|
+
|
|
7
|
+
import { Buffer } from 'node:buffer'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A body that is not the `multipart/form-data` a form reads (`malformed`), or one whose stream failed before its end
|
|
11
|
+
* (`interrupted`: the client closed the connection). Its message names what is wrong, never a byte of it.
|
|
12
|
+
*/
|
|
13
|
+
export class MultipartError extends Error {
|
|
14
|
+
override readonly name = 'MultipartError'
|
|
15
|
+
readonly reason: 'malformed' | 'interrupted'
|
|
16
|
+
|
|
17
|
+
constructor(message: string, reason: 'malformed' | 'interrupted' = 'malformed') {
|
|
18
|
+
super(message)
|
|
19
|
+
this.reason = reason
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MultipartPart {
|
|
24
|
+
/** The `name` of its `Content-Disposition`. */
|
|
25
|
+
readonly name: string
|
|
26
|
+
/** Its `filename`, as sent; `null` when the part has none, which is a plain field. */
|
|
27
|
+
readonly filename: string | null
|
|
28
|
+
/** The media type of its `Content-Type`, lower-cased and without parameters; `null` when it has none. */
|
|
29
|
+
readonly contentType: string | null
|
|
30
|
+
/** Its bytes. Read it to the end, or cancel it, before the next part. */
|
|
31
|
+
readonly body: ReadableStream<Uint8Array>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MultipartReader {
|
|
35
|
+
/** The next part, or `null` after the closing delimiter. Rejects while the previous part's body is unread. */
|
|
36
|
+
next(): Promise<MultipartPart | null>
|
|
37
|
+
/** Stops reading the body. */
|
|
38
|
+
cancel(): Promise<void>
|
|
39
|
+
/** Why the body broke the format, wherever the break was met (a part's body included); `null` while it did not. */
|
|
40
|
+
failure(): MultipartError | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** RFC 2046: 1 to 70 characters of a closed set, the last not a space. */
|
|
44
|
+
const BOUNDARY = /^[0-9A-Za-z'()+_,\-./:=? ]{0,69}[0-9A-Za-z'()+_,\-./:=?]$/
|
|
45
|
+
const CRLF = Buffer.from('\r\n')
|
|
46
|
+
const HEADERS_END = Buffer.from('\r\n\r\n')
|
|
47
|
+
|
|
48
|
+
/** The boundary of a `multipart/form-data` `Content-Type`, or `null` when it is another type or names none. */
|
|
49
|
+
export function multipartBoundary(contentType: string | null): string | null {
|
|
50
|
+
const [type, ...parameters] = splitParameters(contentType ?? '')
|
|
51
|
+
if (type?.toLowerCase() !== 'multipart/form-data') {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
for (const parameter of parameters) {
|
|
55
|
+
const equals = parameter.indexOf('=')
|
|
56
|
+
if (equals > 0 && parameter.slice(0, equals).trim().toLowerCase() === 'boundary') {
|
|
57
|
+
const value = unquote(parameter.slice(equals + 1).trim())
|
|
58
|
+
return value !== null && BOUNDARY.test(value) ? value : null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `a; b="c;d"; e` → `['a', 'b="c;d"', 'e']`: semicolons inside a quoted string do not split. */
|
|
65
|
+
function splitParameters(value: string): string[] {
|
|
66
|
+
const parts: string[] = []
|
|
67
|
+
let current = ''
|
|
68
|
+
let quoted = false
|
|
69
|
+
for (let index = 0; index < value.length; index++) {
|
|
70
|
+
const char = value[index] ?? ''
|
|
71
|
+
if (quoted && char === '\\') {
|
|
72
|
+
current += char + (value[index + 1] ?? '')
|
|
73
|
+
index++
|
|
74
|
+
} else if (char === '"') {
|
|
75
|
+
quoted = !quoted
|
|
76
|
+
current += char
|
|
77
|
+
} else if (char === ';' && !quoted) {
|
|
78
|
+
parts.push(current.trim())
|
|
79
|
+
current = ''
|
|
80
|
+
} else {
|
|
81
|
+
current += char
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
parts.push(current.trim())
|
|
85
|
+
return parts
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A parameter value: a token as it is, a quoted string without its quotes and escapes; `null` when malformed. */
|
|
89
|
+
function unquote(value: string): string | null {
|
|
90
|
+
if (!value.startsWith('"')) {
|
|
91
|
+
return value === '' || value.includes('"') ? null : value
|
|
92
|
+
}
|
|
93
|
+
if (value.length < 2 || !value.endsWith('"')) {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
let text = ''
|
|
97
|
+
for (let index = 1; index < value.length - 1; index++) {
|
|
98
|
+
const char = value[index] ?? ''
|
|
99
|
+
if (char === '\\') {
|
|
100
|
+
index++
|
|
101
|
+
text += value[index] ?? ''
|
|
102
|
+
} else if (char === '"') {
|
|
103
|
+
return null
|
|
104
|
+
} else {
|
|
105
|
+
text += char
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return text
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface PartHeaders {
|
|
112
|
+
readonly name: string
|
|
113
|
+
readonly filename: string | null
|
|
114
|
+
readonly contentType: string | null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function partHeaders(block: Buffer): PartHeaders {
|
|
118
|
+
let disposition: string | null = null
|
|
119
|
+
let contentType: string | null = null
|
|
120
|
+
for (const line of new TextDecoder('utf-8', { fatal: false }).decode(block).split('\r\n')) {
|
|
121
|
+
const colon = line.indexOf(':')
|
|
122
|
+
if (colon <= 0) {
|
|
123
|
+
throw new MultipartError('a part header is not a name and a value')
|
|
124
|
+
}
|
|
125
|
+
const header = line.slice(0, colon).trim().toLowerCase()
|
|
126
|
+
const value = line.slice(colon + 1).trim()
|
|
127
|
+
if (header === 'content-disposition') {
|
|
128
|
+
if (disposition !== null) {
|
|
129
|
+
throw new MultipartError('a part has two Content-Disposition headers')
|
|
130
|
+
}
|
|
131
|
+
disposition = value
|
|
132
|
+
} else if (header === 'content-type') {
|
|
133
|
+
contentType = value.split(';')[0]?.trim().toLowerCase() || null
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const [kind, ...parameters] = splitParameters(disposition ?? '')
|
|
137
|
+
if (kind?.toLowerCase() !== 'form-data') {
|
|
138
|
+
throw new MultipartError('a part is not form-data (Content-Disposition: form-data; name="…")')
|
|
139
|
+
}
|
|
140
|
+
let name: string | null = null
|
|
141
|
+
let filename: string | null = null
|
|
142
|
+
for (const parameter of parameters) {
|
|
143
|
+
const equals = parameter.indexOf('=')
|
|
144
|
+
const key = equals > 0 ? parameter.slice(0, equals).trim().toLowerCase() : ''
|
|
145
|
+
const value = equals > 0 ? unquote(parameter.slice(equals + 1).trim()) : null
|
|
146
|
+
if (key === 'name' || key === 'filename') {
|
|
147
|
+
if (value === null || (key === 'name' ? name : filename) !== null) {
|
|
148
|
+
throw new MultipartError(`a part's ${key} is malformed or given twice`)
|
|
149
|
+
}
|
|
150
|
+
if (key === 'name') {
|
|
151
|
+
name = value
|
|
152
|
+
} else {
|
|
153
|
+
filename = value
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (name === null || name === '') {
|
|
158
|
+
throw new MultipartError('a part has no name')
|
|
159
|
+
}
|
|
160
|
+
return { name, filename, contentType }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Reads `body` as `multipart/form-data` with `boundary`. A part's headers hold at most `maxHeaderBytes`; its body is
|
|
165
|
+
* as long as the sender makes it, so the caller bounds what it reads of each.
|
|
166
|
+
*/
|
|
167
|
+
export function multipartParts(body: ReadableStream<Uint8Array>, boundary: string, options: { readonly maxHeaderBytes: number }): MultipartReader {
|
|
168
|
+
const source = body.getReader()
|
|
169
|
+
const delimiter = Buffer.from(`\r\n--${boundary}`)
|
|
170
|
+
// The body starts with the first delimiter, without the CRLF that precedes every later one.
|
|
171
|
+
let buffer: Buffer = Buffer.from(CRLF)
|
|
172
|
+
let ended = false
|
|
173
|
+
/** A part's body is being read: the next part waits until it ended. */
|
|
174
|
+
let reading = false
|
|
175
|
+
/** The closing delimiter was read: there is no further part. */
|
|
176
|
+
let finished = false
|
|
177
|
+
/** Reading stopped before the closing delimiter: no further part can be told from a missing one. */
|
|
178
|
+
let cancelled = false
|
|
179
|
+
let failed: MultipartError | null = null
|
|
180
|
+
|
|
181
|
+
/** A break of the format; once reading was cancelled, the end it meets is the cancellation's, not the sender's. */
|
|
182
|
+
const fail = (message: string, reason: 'malformed' | 'interrupted' = 'malformed'): never => {
|
|
183
|
+
if (cancelled) {
|
|
184
|
+
throw new MultipartError('the body was not read to its end')
|
|
185
|
+
}
|
|
186
|
+
failed = new MultipartError(message, reason)
|
|
187
|
+
throw failed
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Reads more of the body into the buffer; `false` once the body ended. */
|
|
191
|
+
async function fill(): Promise<boolean> {
|
|
192
|
+
if (ended) {
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
let next: Awaited<ReturnType<typeof source.read>>
|
|
196
|
+
try {
|
|
197
|
+
next = await source.read()
|
|
198
|
+
} catch {
|
|
199
|
+
// A closed connection, or a body stream that failed: the sender's, never the application's.
|
|
200
|
+
return fail('the body could not be read to its end', 'interrupted')
|
|
201
|
+
}
|
|
202
|
+
if (next.done) {
|
|
203
|
+
ended = true
|
|
204
|
+
return false
|
|
205
|
+
}
|
|
206
|
+
buffer = buffer.byteLength === 0 ? Buffer.from(next.value) : Buffer.concat([buffer, next.value])
|
|
207
|
+
return true
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** At least `size` bytes in the buffer, or the body ended first. */
|
|
211
|
+
async function want(size: number): Promise<boolean> {
|
|
212
|
+
while (buffer.byteLength < size) {
|
|
213
|
+
if (!(await fill())) {
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The bytes after a delimiter: `--` closes the body, CRLF starts the next part's headers. */
|
|
221
|
+
async function afterDelimiter(): Promise<'part' | 'end'> {
|
|
222
|
+
if (!(await want(2))) {
|
|
223
|
+
return fail('the body ends inside a delimiter')
|
|
224
|
+
}
|
|
225
|
+
const marker = buffer.subarray(0, 2).toString('latin1')
|
|
226
|
+
buffer = buffer.subarray(2)
|
|
227
|
+
if (marker === '--') {
|
|
228
|
+
return 'end'
|
|
229
|
+
}
|
|
230
|
+
if (marker !== '\r\n') {
|
|
231
|
+
return fail('a delimiter is followed by neither CRLF nor --')
|
|
232
|
+
}
|
|
233
|
+
return 'part'
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function headers(): Promise<PartHeaders> {
|
|
237
|
+
for (;;) {
|
|
238
|
+
const end = buffer.indexOf(HEADERS_END)
|
|
239
|
+
if (end > options.maxHeaderBytes || (end < 0 && buffer.byteLength > options.maxHeaderBytes)) {
|
|
240
|
+
return fail(`a part's headers are longer than ${options.maxHeaderBytes} bytes`)
|
|
241
|
+
}
|
|
242
|
+
if (end >= 0) {
|
|
243
|
+
const block = buffer.subarray(0, end)
|
|
244
|
+
buffer = buffer.subarray(end + HEADERS_END.byteLength)
|
|
245
|
+
try {
|
|
246
|
+
return partHeaders(block)
|
|
247
|
+
} catch (error) {
|
|
248
|
+
return fail(error instanceof Error ? error.message : 'a part header is malformed')
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (!(await fill())) {
|
|
252
|
+
return fail("the body ends inside a part's headers")
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function partBody(): ReadableStream<Uint8Array> {
|
|
258
|
+
return new ReadableStream<Uint8Array>({
|
|
259
|
+
async pull(controller) {
|
|
260
|
+
try {
|
|
261
|
+
for (;;) {
|
|
262
|
+
const at = buffer.indexOf(delimiter)
|
|
263
|
+
if (at >= 0) {
|
|
264
|
+
if (at > 0) {
|
|
265
|
+
controller.enqueue(new Uint8Array(buffer.subarray(0, at)))
|
|
266
|
+
}
|
|
267
|
+
buffer = buffer.subarray(at + delimiter.byteLength)
|
|
268
|
+
reading = false
|
|
269
|
+
controller.close()
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
// Keep what could be the start of the delimiter; everything before it is the part's.
|
|
273
|
+
const safe = buffer.byteLength - (delimiter.byteLength - 1)
|
|
274
|
+
if (safe > 0) {
|
|
275
|
+
controller.enqueue(new Uint8Array(buffer.subarray(0, safe)))
|
|
276
|
+
buffer = buffer.subarray(safe)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
if (!(await fill())) {
|
|
280
|
+
fail('the body ends inside a part')
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
} catch (error) {
|
|
284
|
+
controller.error(error)
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
async cancel() {
|
|
288
|
+
cancelled = true
|
|
289
|
+
await source.cancel().catch(() => {})
|
|
290
|
+
},
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let started = false
|
|
295
|
+
return {
|
|
296
|
+
async next(): Promise<MultipartPart | null> {
|
|
297
|
+
if (finished) {
|
|
298
|
+
return null
|
|
299
|
+
}
|
|
300
|
+
if (cancelled) {
|
|
301
|
+
throw new MultipartError('the body was not read to its end')
|
|
302
|
+
}
|
|
303
|
+
if (failed !== null) {
|
|
304
|
+
throw failed
|
|
305
|
+
}
|
|
306
|
+
if (reading) {
|
|
307
|
+
throw new MultipartError("the previous part's body was not read to its end")
|
|
308
|
+
}
|
|
309
|
+
if (!started) {
|
|
310
|
+
started = true
|
|
311
|
+
if (!(await want(delimiter.byteLength)) || buffer.indexOf(delimiter) !== 0) {
|
|
312
|
+
return fail('the body does not start with its boundary')
|
|
313
|
+
}
|
|
314
|
+
buffer = buffer.subarray(delimiter.byteLength)
|
|
315
|
+
}
|
|
316
|
+
if ((await afterDelimiter()) === 'end') {
|
|
317
|
+
finished = true
|
|
318
|
+
// The epilogue is the sender's; nothing reads it.
|
|
319
|
+
await source.cancel().catch(() => {})
|
|
320
|
+
return null
|
|
321
|
+
}
|
|
322
|
+
const found = await headers()
|
|
323
|
+
reading = true
|
|
324
|
+
return Object.freeze({ ...found, body: partBody() })
|
|
325
|
+
},
|
|
326
|
+
async cancel(): Promise<void> {
|
|
327
|
+
cancelled = !finished
|
|
328
|
+
await source.cancel().catch(() => {})
|
|
329
|
+
},
|
|
330
|
+
failure: () => failed,
|
|
331
|
+
}
|
|
332
|
+
}
|